Predicting the future is something we all try to do. Whether it’s energy consumption, sensor readings, or production metrics, having a reliable forecast helps us make better decisions. The problem is that building a good forecasting model traditionally requires deep statistical knowledge, and a lot of tuning. What if we could just hand our data to an AI agent and ask “what’s going to happen next”?
That’s exactly what this project does. It combines Strands Agents with Amazon Chronos-Bolt, a foundation model for time series forecasting available on AWS Bedrock Marketplace, to create an AI agent that can forecast any numerical time series through natural language.
The architecture
The idea is simple. We have a Strands Agent powered by Claude (via AWS Bedrock) that understands natural language. When the user asks for a forecast, the agent calls a custom tool that invokes Chronos-Bolt to generate predictions. The agent then interprets the results and explains them in plain language.

The key here is that the agent doesn’t just return raw numbers. It understands the context, explains trends, and presents the confidence intervals in a way that makes sense.
The forecast tool
The tool is defined using the @tool decorator from Strands. This decorator turns a regular Python function into something the agent can discover and invoke on its own:
tooldef forecast_time_series( values: Annotated[ list[float], "Historical time series values in chronological order. " "Values should be evenly spaced (e.g., hourly, daily). Minimum 10 values.", ], prediction_length: Annotated[ int, "Number of future steps to predict. " "Uses the same time unit as the input data.", ], quantile_levels: Annotated[ Optional[list[float]], "Quantile levels for confidence intervals. Default: [0.1, 0.5, 0.9]. " "0.5 is the median forecast, 0.1 and 0.9 define the 80% confidence band.", ] = None,) -> dict:
The Annotated type hints serve a dual purpose: they validate types at runtime and provide descriptions that the LLM reads to understand how to use the tool. This means the agent knows it needs a list of floats, a prediction length, and optionally custom quantile levels, all from the type annotations alone.
The tool validates the input (minimum 10 values, maximum 50,000, prediction length between 1 and 1,000), filters out NaN values, and then calls the Chronos-Bolt client:
result = invoke_chronos( values=clean_values, prediction_length=prediction_length, quantile_levels=quantile_levels,)return { "status": "success", "content": [{"text": "\n".join(summary_lines)}], "metadata": { "quantiles": result.quantiles, "prediction_length": result.prediction_length, "history_length": result.history_length, },}
The response includes both a human-readable summary (in content) and the raw quantile data (in metadata), so the agent can reference exact numbers when explaining the forecast.
The Chronos-Bolt client
Chronos-Bolt is accessed through the Bedrock runtime API. The client sends the historical values and receives predictions at different quantile levels:
def invoke_chronos( values: list[float], prediction_length: int, quantile_levels: list[float] | None = None,) -> ForecastResult: client = _get_bedrock_runtime_client() payload = { "inputs": [{"target": values}], "parameters": { "prediction_length": prediction_length, "quantile_levels": quantiles, }, } response = client.invoke_model( modelId=CHRONOS_ENDPOINT_ARN, body=json.dumps(payload), contentType="application/json", accept="application/json", )
The invoke_model call uses the SageMaker endpoint ARN deployed through Bedrock Marketplace. Chronos-Bolt returns predictions organized by quantile levels, by default, the 10th, 50th (median), and 90th percentiles. This gives us not just a single forecast line, but a confidence band: the 80% interval between the 10th and 90th percentiles tells us how uncertain the model is about its predictions.
The Bedrock runtime client is configured with generous timeouts (120s read, 30s connect) and automatic retries, since inference on time series data can take a moment depending on the history length:
def _get_bedrock_runtime_client(): return boto3.client( "bedrock-runtime", region_name=AWS_REGION, config=Config( read_timeout=120, connect_timeout=30, retries={"max_attempts": 3}, ), )
The agent
Wiring everything together is straightforward. We create a BedrockModel pointing to Claude and pass our forecast tool to the Agent:
from strands import Agentfrom strands.models.bedrock import BedrockModelfrom settings import AWS_REGION, Modelsfrom forecast import forecast_time_seriesSYSTEM_PROMPT = """You are a time series forecasting assistant powered by Amazon Chronos-Bolt.You help users predict future values from historical numerical data. When a user providestime series data or describes a scenario, use the forecast_time_series tool to generatepredictions.When presenting results:- Show the median forecast (quantile 0.5) as the main prediction- Explain the confidence band (quantiles 0.1 and 0.9) as the uncertainty range- Summarize trends in plain language"""def create_agent() -> Agent: bedrock_model = BedrockModel( model_id=Models.CLAUDE_SONNET, region_name=AWS_REGION, ) return Agent( model=bedrock_model, system_prompt=SYSTEM_PROMPT, tools=[forecast_time_series], )
The system prompt is important here. It tells Claude that it has forecasting capabilities and how to present the results. Without it, the agent would still call the tool correctly (thanks to the Annotated descriptions), but it might not explain the confidence bands or summarize trends as clearly.
Running it
The CLI entry point (cli.py) registers commands and wires everything together. The forecast command generates synthetic hourly data (a sine wave with noise) by default and asks the agent to forecast. You can also pass a custom prompt.
The entry point is minimal:
import clickfrom commands.forecast import run as forecastclick.group()def cli(): passcli.add_command(cmd=forecast, name="forecast")if __name__ == "__main__": cli()
The actual command lives in commands/forecast.py:
click.command()click.option("--prompt", "-p", default=None, help="Custom prompt for the agent.")def run(prompt: str | None): agent = create_agent() if prompt is None: values = generate_sample_data(num_points=100) values_str = ", ".join(f"{v:.2f}" for v in values) prompt = ( f"I have the following hourly sensor readings from the last 100 hours:\n" f"[{values_str}]\n\n" f"Please forecast the next 24 hours and explain the predicted trend." ) response = agent(prompt) click.echo(response)
The sine wave is a good choice for a demo because it has a clear periodic pattern that Chronos-Bolt should capture well. With 100 hours of history (about 4 full cycles of a 24-hour pattern), the model has enough data to identify the periodicity and project it forward.
Example
(venv) ➜ src python cli.py forecast2026-02-27 14:11:16,471 - INFO - Found credentials in shared credentials file: ~/.aws/credentials2026-02-27 14:11:16,506 - INFO - Creating Strands MetricsClientSure! Let me run the forecast on your 100-hour sensor readings right away.Tool #1: forecast_time_series2026-02-27 14:11:22,981 - INFO - Starting forecast: history=100, prediction_length=242026-02-27 14:11:22,981 - INFO - Invoking Chronos-Bolt: history_length=100, prediction_length=24, quantiles=[0.1, 0.5, 0.9]2026-02-27 14:11:22,994 - INFO - Found credentials in shared credentials file: ~/.aws/credentials2026-02-27 14:11:23,697 - INFO - Forecast complete: 24 steps, 4 quantilesHere are the results of your 24-hour forecast:
Script response
📊 24-Hour Sensor Forecast
| Hour | Low (10th %) | Median (50th %) | High (90th %) |
|---|---|---|---|
| +1 | ~-0.29 | ~0.85 | ~0.99 |
| +2 | ~0.29 | ~0.95 | ~0.99 |
| +3 | ~0.49 | ~0.99 | ~0.99 |
| +4 | ~0.47 | ~0.83 | ~0.99 |
| +5 | ~0.24 | ~0.72 | ~0.96 |
| +6 | ~0.09 | ~0.53 | ~0.83 |
| +7 | ~-0.25 | ~0.19 | ~0.59 |
| +8 | ~-0.60 | ~-0.22 | ~0.25 |
| +9 | ~-0.91 | ~-0.51 | ~-0.04 |
| +10 | ~-0.99 | ~-0.76 | ~-0.30 |
| +11 | ~-0.99 | ~-0.87 | ~-0.46 |
| +12 | ~-0.99 | ~-0.93 | ~-0.56 |
| +13 | ~-0.99 | ~-0.96 | ~-0.57 |
| +14 | ~-0.99 | ~-0.93 | ~-0.52 |
| +15 | ~-0.99 | ~-0.87 | ~-0.44 |
| +16 | ~-0.99 | ~-0.79 | ~-0.35 |
| +17 | ~-0.96 | ~-0.63 | ~-0.16 |
| +18 | ~-0.86 | ~-0.46 | ~0.02 |
| +19 | ~-0.66 | ~-0.24 | ~0.21 |
| +20 | ~-0.44 | ~-0.01 | ~0.42 |
| +21 | ~-0.21 | ~0.22 | ~0.60 |
| +22 | ~-0.04 | ~0.45 | ~0.78 |
| +23 | ~0.16 | ~0.66 | ~0.90 |
| +24 | ~0.31 | ~0.80 | ~0.96 |
🔍 Trend Explanation
Your sensor data clearly exhibits a sinusoidal/oscillating pattern — cycling between peaks near +1.0 and troughs near -1.0 roughly every ~24 hours. The model has picked this up beautifully! Here’s what to expect:
-
Hours +1 to +4 — Continued Peak 🔼: The signal is currently rising (ending at ~0.67–0.70), and the model predicts it will crest near ~+0.85 to +0.99 in the first few hours — completing the current positive phase.
-
Hours +5 to +13 — Descent & Trough 🔽: The signal then descends sharply, hitting a trough around hours +12 to +14 with a median near -0.93 to -0.96 — matching the negative peaks seen in the historical data.
-
Hours +14 to +24 — Recovery 🔼: After bottoming out, the signal climbs back up, reaching approximately +0.80 by hour +24, setting up the next positive cycle.
📐 Confidence Band
The 80% confidence interval (low–high columns) is relatively tight, reflecting the model’s high confidence in the periodic nature of this signal. The widest uncertainty occurs around the transition zones (hours +7–+9 and +17–+19), which is typical for oscillating signals near the zero-crossing points.
In short: your sensor is behaving like a clean oscillating signal with an ~24-hour period, and the next full cycle looks very consistent with historical behavior.Here are the results of your 24-hour forecast:
📊 24-Hour Sensor Forecast
| Hour | Low (10th %) | Median (50th %) | High (90th %) |
|---|---|---|---|
| +1 | ~-0.29 | ~0.85 | ~0.99 |
| +2 | ~0.29 | ~0.95 | ~0.99 |
| +3 | ~0.49 | ~0.99 | ~0.99 |
| +4 | ~0.47 | ~0.83 | ~0.99 |
| +5 | ~0.24 | ~0.72 | ~0.96 |
| +6 | ~0.09 | ~0.53 | ~0.83 |
| +7 | ~-0.25 | ~0.19 | ~0.59 |
| +8 | ~-0.60 | ~-0.22 | ~0.25 |
| +9 | ~-0.91 | ~-0.51 | ~-0.04 |
| +10 | ~-0.99 | ~-0.76 | ~-0.30 |
| +11 | ~-0.99 | ~-0.87 | ~-0.46 |
| +12 | ~-0.99 | ~-0.93 | ~-0.56 |
| +13 | ~-0.99 | ~-0.96 | ~-0.57 |
| +14 | ~-0.99 | ~-0.93 | ~-0.52 |
| +15 | ~-0.99 | ~-0.87 | ~-0.44 |
| +16 | ~-0.99 | ~-0.79 | ~-0.35 |
| +17 | ~-0.96 | ~-0.63 | ~-0.16 |
| +18 | ~-0.86 | ~-0.46 | ~0.02 |
| +19 | ~-0.66 | ~-0.24 | ~0.21 |
| +20 | ~-0.44 | ~-0.01 | ~0.42 |
| +21 | ~-0.21 | ~0.22 | ~0.60 |
| +22 | ~-0.04 | ~0.45 | ~0.78 |
| +23 | ~0.16 | ~0.66 | ~0.90 |
| +24 | ~0.31 | ~0.80 | ~0.96 |
🔍 Trend Explanation
Your sensor data clearly exhibits a sinusoidal/oscillating pattern — cycling between peaks near +1.0 and troughs near -1.0 roughly every ~24 hours. The model has picked this up beautifully! Here’s what to expect:
-
Hours +1 to +4 — Continued Peak 🔼: The signal is currently rising (ending at ~0.67–0.70), and the model predicts it will crest near ~+0.85 to +0.99 in the first few hours — completing the current positive phase.
-
Hours +5 to +13 — Descent & Trough 🔽: The signal then descends sharply, hitting a trough around hours +12 to +14 with a median near -0.93 to -0.96 — matching the negative peaks seen in the historical data.
-
Hours +14 to +24 — Recovery 🔼: After bottoming out, the signal climbs back up, reaching approximately +0.80 by hour +24, setting up the next positive cycle.
📐 Confidence Band
The 80% confidence interval (low–high columns) is relatively tight, reflecting the model’s high confidence in the periodic nature of this signal. The widest uncertainty occurs around the transition zones (hours +7–+9 and +17–+19), which is typical for oscillating signals near the zero-crossing points.
In short: your sensor is behaving like a clean oscillating signal with an ~24-hour period, and the next full cycle looks very consistent with historical behavior.
And that’s all! Full code in my GitHub account.














