An “agent” is just a loop. It does three things:
- Take a prompt, pass it on to the LLM, and get the response.
- If the response contains tool calls, execute the tools and return the results to the LLM.
- Break out of the loop if there are no more tool calls in the response, and return the response.
For example, consider this otherwise useless agent that suggests what to pack for your upcoming flight.
for my flight QF1?]) --> llm subgraph agentloop [The agent loop] direction TB llm{{LLM}} -->|"1 · get_flight_status('QF1')"| f["Flight API → London"] f --> llm llm -->|"2 · get_weather('London')"| w["Weather API → 6°C, clear"] w --> llm end llm -->|no tool calls| out([Pack a warm coat,
a scarf, and gloves.])
def get_flight_status(flight):
return ... # QF1 → London
def get_weather(city):
return ... # London → 6°C, clear
tools = {"get_flight_status": get_flight_status, "get_weather": get_weather}
messages = [{"role": "user", "content": "What should I pack for my flight QF1?"}]
while True:
response = llm(messages, tools)
messages.append(response)
if not response.tool_calls:
break
for call in response.tool_calls:
result = tools[call.name](**call.args)
messages.append({"role": "tool", "content": result})
print(response.text) # Pack a warm coat, a scarf, and gloves.
- Based on the prompt, the LLM decided to call
get_flight_status("QF1")to retrieve the destination. - The loop continues, and the tool result is returned to the LLM, after which it decides to call
get_weather("London"). - The loop continues again, and the weather in London is returned to the LLM.
- The LLM now has all the data to decide on the appropriate apparel and produce the response without any tool calls.
- The loop breaks. The user sees the final response.
while the loop is simple, a lot can go wrong and make it unreliable:
- The APIs might have a transient issue (rate limits or
5xxerrors) that could bring down the entire loop. If it fails during the Nth turn, all the N LLM calls are wasted. - The upstream model provider can have an outage. You know the kind when Claude’s status page goes Christmas red in the middle of June.
- The process itself can die in long-running loops (which are fairly common now; my Claude Code sessions regularly run for more than 30 minutes) when the machine restarts (during a new deploy, due to an issue, etc.), and there’ll be no way to recover from the middle of the agent loop.
A better solution is to use a durable execution platform like Temporal, which handles all of these for you. When a tool call fails, it is retried. If your machine restarts, it resumes from the exact iteration without having to spend all that money again on the same tokens.
I’ve been using Temporal for the past year to build all my agents. While Temporal has been around for a while as a building block for durable systems (e.g., payment flows), it naturally fits the kinds of problems we face when building agents. Temporal is also free and open source, and it has SDKs in all major programming languages.
My goal is to share how I’ve been using it to make my agent loops durable and, as a bonus, observable, using the example apparel-suggestion agent from before. I would suggest everyone first go through the quickstart guide of the programming language of their choice before proceeding, as I don’t want to parrot the docs here.
Starting the Temporal Development Server
I have the Temporal CLI installed on my local machine, which helps me spin up a local Temporal server for development. It also helps me connect to and monitor my production Temporal instances. To install the CLI:
brew install temporal
Download the Temporal CLI archive for your architecture:
Extract it and add temporal.exe to your PATH.
Download the Temporal CLI for your architecture:
Extract the archive and move the temporal binary into your PATH, for example:
sudo mv temporal /usr/local/bin
Then start the dev server:
temporal server start-dev
The Temporal server runs on port 7233, and a web UI will be available at http://localhost:8233.
TIP
You can also run Temporal inside Docker if you don’t want to install the CLI.
Unreliable Agent
Let’s start with the unreliable agent from our previous example. I will use OpenAI’s model and SDK, but you could just as well use any other platform.
IMPORTANT
Set the
OPENAI_API_KEYenvironment variable before running the script.
import json
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from the environment
MODEL = "gpt-5.4-nano-2026-03-17"
def get_flight_status(flight):
return json.dumps({"flight": flight, "destination": "London (LHR)", "status": "on time"})
def get_weather(city):
return json.dumps({"city": city, "temperature_c": 6, "conditions": "clear"})
TOOL_IMPLS = {"get_flight_status": get_flight_status, "get_weather": get_weather}
SYSTEM_PROMPT = (
"You are a travel assistant. Given a flight number, use get_flight_status to find "
"the destination, then get_weather for that city, then tell the user what to pack "
"in one short sentence."
)
TOOLS = [
{
"type": "function",
"function": {
"name": "get_flight_status",
"description": "Look up the destination city and status of a flight by its number.",
"parameters": {
"type": "object",
"properties": {"flight": {"type": "string", "description": "Flight number, e.g. QF1"}},
"required": ["flight"],
},
},
},
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string", "description": "City name, e.g. London"}},
"required": ["city"],
},
},
},
]
def call_llm(messages):
completion = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
parallel_tool_calls=False,
)
return completion.choices[0].message
def run_tool(tool_call):
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
return TOOL_IMPLS[name](**args)
def run_agent(goal):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": goal},
]
while True:
message = call_llm(messages)
messages.append(message)
if not message.tool_calls:
return message.content
result = run_tool(message.tool_calls[0])
messages.append(
{"role": "tool", "tool_call_id": message.tool_calls[0].id, "content": result}
)
if __name__ == "__main__":
print(run_agent("What should I pack for my flight QF1?"))
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/shared"
)
const model = "gpt-5.4-nano-2026-03-17"
const systemPrompt = "You are a travel assistant. Given a flight number, use get_flight_status to find " +
"the destination, then get_weather for that city, then tell the user what to pack in one short sentence."
func getFlightStatus(flight string) string {
b, _ := json.Marshal(map[string]any{"flight": flight, "destination": "London (LHR)", "status": "on time"})
return string(b)
}
func getWeather(city string) string {
b, _ := json.Marshal(map[string]any{"city": city, "temperature_c": 6, "conditions": "clear"})
return string(b)
}
var tools = []openai.ChatCompletionToolUnionParam{
openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{
Name: "get_flight_status",
Description: openai.String("Look up the destination city and status of a flight by its number."),
Parameters: shared.FunctionParameters{
"type": "object",
"properties": map[string]any{
"flight": map[string]any{"type": "string", "description": "Flight number, e.g. QF1"},
},
"required": []string{"flight"},
},
}),
openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{
Name: "get_weather",
Description: openai.String("Get the current weather for a city."),
Parameters: shared.FunctionParameters{
"type": "object",
"properties": map[string]any{
"city": map[string]any{"type": "string", "description": "City name, e.g. London"},
},
"required": []string{"city"},
},
}),
}
func runTool(name, arguments string) string {
switch name {
case "get_flight_status":
var args struct {
Flight string `json:"flight"`
}
json.Unmarshal([]byte(arguments), &args)
return getFlightStatus(args.Flight)
case "get_weather":
var args struct {
City string `json:"city"`
}
json.Unmarshal([]byte(arguments), &args)
return getWeather(args.City)
default:
return fmt.Sprintf("unknown tool: %s", name)
}
}
func runAgent(ctx context.Context, client openai.Client, goal string) (string, error) {
messages := []openai.ChatCompletionMessageParamUnion{
openai.SystemMessage(systemPrompt),
openai.UserMessage(goal),
}
for {
completion, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
Model: model,
Messages: messages,
Tools: tools,
ParallelToolCalls: openai.Bool(false),
})
if err != nil {
return "", err
}
message := completion.Choices[0].Message
messages = append(messages, message.ToParam())
if len(message.ToolCalls) == 0 {
return message.Content, nil
}
call := message.ToolCalls[0]
result := runTool(call.Function.Name, call.Function.Arguments)
messages = append(messages, openai.ToolMessage(result, call.ID))
}
}
func main() {
client := openai.NewClient() // reads OPENAI_API_KEY from the environment
answer, err := runAgent(context.Background(), client, "What should I pack for my flight QF1?")
if err != nil {
log.Fatal(err)
}
fmt.Println(answer)
}
Try running this, and you’ll see the final response. Now let’s see how Temporal makes it durable.
Durability with Temporal
The agent loop maps to a workflow in Temporal. It orchestrates a sequence of steps deterministically. The actual executions—the LLM call and the tool calls—happen inside a Temporal activity.
Let’s start by moving the LLM and tool call functions into activities.
1import json
2from dataclasses import dataclass
3
4from openai import OpenAI
5from temporalio import activity
6
7client = OpenAI() # reads OPENAI_API_KEY from the environment
8MODEL = "gpt-5.4-nano-2026-03-17"
9
10def get_flight_status(flight):
11 return json.dumps({"flight": flight, "destination": "London (LHR)", "status": "on time"})
12
13def get_weather(city):
14 return json.dumps({"city": city, "temperature_c": 6, "conditions": "clear"})
15
16TOOL_IMPLS = {"get_flight_status": get_flight_status, "get_weather": get_weather}
17
18SYSTEM_PROMPT = (
19 "You are a travel assistant. Given a flight number, use get_flight_status to find "
20 "the destination, then get_weather for that city, then tell the user what to pack "
21 "in one short sentence."
22)
23
24TOOLS = [
25 {
26 "type": "function",
27 "function": {
28 "name": "get_flight_status",
29 "description": "Look up the destination city and status of a flight by its number.",
30 "parameters": {
31 "type": "object",
32 "properties": {"flight": {"type": "string", "description": "Flight number, e.g. QF1"}},
33 "required": ["flight"],
34 },
35 },
36 },
37 {
38 "type": "function",
39 "function": {
40 "name": "get_weather",
41 "description": "Get the current weather for a city.",
42 "parameters": {
43 "type": "object",
44 "properties": {"city": {"type": "string", "description": "City name, e.g. London"}},
45 "required": ["city"],
46 },
47 },
48 },
49]
50
51@dataclass
52class LLMResponse:
53 content: str | None
54 message: dict
55 tool_calls: list[dict]
56 tool_call: dict | None
57
58@activity.defn
59def call_llm(messages: list[dict]) -> LLMResponse:
60 completion = client.chat.completions.create(
61 model=MODEL,
62 messages=messages,
63 tools=TOOLS,
64 parallel_tool_calls=False,
65 )
66 message = completion.choices[0].message
67 tool_calls = [call.model_dump() for call in message.tool_calls or []]
68 return LLMResponse(
69 content=message.content,
70 message=message.model_dump(exclude_none=True),
71 tool_calls=tool_calls,
72 tool_call=tool_calls[0] if tool_calls else None,
73 )
74
75@activity.defn
76def run_tool(tool_call: dict) -> str:
77 name = tool_call["function"]["name"]
78 args = json.loads(tool_call["function"]["arguments"])
79 return TOOL_IMPLS[name](**args)
Here’s what we changed:
- 5: Import Temporal’s
activitydecorator. - 51-56:
LLMResponseis a dataclass to serialize whatever an activity returns to be stored in Temporal (and used in replays). - 58 and 75: The
@activity.defndecorator makes Temporal treat them as activities and remember their return values. - 67-73:
call_llmmakes the same API call as before but hands back the dataclass, andmodel_dump()flattens the SDK object into plain dicts.
1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7
8 "github.com/openai/openai-go/v3"
9 "github.com/openai/openai-go/v3/shared"
10)
11
12type Message struct {
13 Role string `json:"role"`
14 Content string `json:"content,omitempty"`
15 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
16 ToolCallID string `json:"tool_call_id,omitempty"`
17}
18
19type ToolCall struct {
20 ID string `json:"id"`
21 Name string `json:"name"`
22 Arguments string `json:"arguments"`
23}
24
25type LLMResponse struct {
26 Content string `json:"content"`
27 Message Message `json:"message"`
28 ToolCalls []ToolCall `json:"tool_calls"`
29 ToolCall ToolCall `json:"tool_call"`
30}
31
32const model = "gpt-5.4-nano-2026-03-17"
33
34const systemPrompt = "You are a travel assistant. Given a flight number, use get_flight_status to find " +
35 "the destination, then get_weather for that city, then tell the user what to pack in one short sentence."
36
37var client = openai.NewClient() // reads OPENAI_API_KEY from the environment
38
39func getFlightStatus(flight string) string {
40 b, _ := json.Marshal(map[string]any{"flight": flight, "destination": "London (LHR)", "status": "on time"})
41 return string(b)
42}
43
44func getWeather(city string) string {
45 b, _ := json.Marshal(map[string]any{"city": city, "temperature_c": 6, "conditions": "clear"})
46 return string(b)
47}
48
49var tools = []openai.ChatCompletionToolUnionParam{
50 openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{
51 Name: "get_flight_status",
52 Description: openai.String("Look up the destination city and status of a flight by its number."),
53 Parameters: shared.FunctionParameters{
54 "type": "object",
55 "properties": map[string]any{
56 "flight": map[string]any{"type": "string", "description": "Flight number, e.g. QF1"},
57 },
58 "required": []string{"flight"},
59 },
60 }),
61 openai.ChatCompletionFunctionTool(shared.FunctionDefinitionParam{
62 Name: "get_weather",
63 Description: openai.String("Get the current weather for a city."),
64 Parameters: shared.FunctionParameters{
65 "type": "object",
66 "properties": map[string]any{
67 "city": map[string]any{"type": "string", "description": "City name, e.g. London"},
68 },
69 "required": []string{"city"},
70 },
71 }),
72}
73
74func toParams(messages []Message) []openai.ChatCompletionMessageParamUnion {
75 var params []openai.ChatCompletionMessageParamUnion
76 for _, m := range messages {
77 switch m.Role {
78 case "system":
79 params = append(params, openai.SystemMessage(m.Content))
80 case "user":
81 params = append(params, openai.UserMessage(m.Content))
82 case "tool":
83 params = append(params, openai.ToolMessage(m.Content, m.ToolCallID))
84 case "assistant":
85 assistant := openai.ChatCompletionAssistantMessageParam{}
86 if m.Content != "" {
87 assistant.Content.OfString = openai.String(m.Content)
88 }
89 for _, call := range m.ToolCalls {
90 assistant.ToolCalls = append(assistant.ToolCalls, openai.ChatCompletionMessageToolCallUnionParam{
91 OfFunction: &openai.ChatCompletionMessageFunctionToolCallParam{
92 ID: call.ID,
93 Function: openai.ChatCompletionMessageFunctionToolCallFunctionParam{
94 Name: call.Name,
95 Arguments: call.Arguments,
96 },
97 },
98 })
99 }
100 params = append(params, openai.ChatCompletionMessageParamUnion{OfAssistant: &assistant})
101 }
102 }
103 return params
104}
105
106func CallLLM(ctx context.Context, messages []Message) (LLMResponse, error) {
107 completion, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{
108 Model: model,
109 Messages: toParams(messages),
110 Tools: tools,
111 ParallelToolCalls: openai.Bool(false),
112 })
113 if err != nil {
114 return LLMResponse{}, err
115 }
116
117 message := completion.Choices[0].Message
118 var toolCalls []ToolCall
119 for _, call := range message.ToolCalls {
120 toolCalls = append(toolCalls, ToolCall{
121 ID: call.ID,
122 Name: call.Function.Name,
123 Arguments: call.Function.Arguments,
124 })
125 }
126
127 response := LLMResponse{
128 Content: message.Content,
129 Message: Message{Role: "assistant", Content: message.Content, ToolCalls: toolCalls},
130 ToolCalls: toolCalls,
131 }
132 if len(toolCalls) > 0 {
133 response.ToolCall = toolCalls[0]
134 }
135 return response, nil
136}
137
138func RunTool(ctx context.Context, call ToolCall) (string, error) {
139 switch call.Name {
140 case "get_flight_status":
141 var args struct {
142 Flight string `json:"flight"`
143 }
144 json.Unmarshal([]byte(call.Arguments), &args)
145 return getFlightStatus(args.Flight), nil
146 case "get_weather":
147 var args struct {
148 City string `json:"city"`
149 }
150 json.Unmarshal([]byte(call.Arguments), &args)
151 return getWeather(args.City), nil
152 default:
153 return "", fmt.Errorf("unknown tool: %s", call.Name)
154 }
155}
Here’s what we changed:
Now we can orchestrate these activities through a workflow. i.e., instead of calling the LLM and tool functions directly, we run them as Temporal activities. Temporal stores each activity result in the workflow’s event history, and if the process dies mid-run, these results are used instead of rerunning.
1from datetime import timedelta
2
3from temporalio import workflow
4from temporalio.common import RetryPolicy
5
6with workflow.unsafe.imports_passed_through():
7 from activities import call_llm, run_tool, SYSTEM_PROMPT
8
9@workflow.defn
10class AgentWorkflow:
11 @workflow.run
12 async def run(self, goal: str) -> str:
13 messages = [
14 {"role": "system", "content": SYSTEM_PROMPT},
15 {"role": "user", "content": goal},
16 ]
17 while True:
18 response = await workflow.execute_activity(
19 call_llm,
20 args=[messages],
21 start_to_close_timeout=timedelta(seconds=60),
22 retry_policy=RetryPolicy(maximum_attempts=5),
23 )
24 messages.append(response.message)
25
26 if not response.tool_calls:
27 return response.content
28
29 result = await workflow.execute_activity(
30 run_tool,
31 args=[response.tool_call],
32 start_to_close_timeout=timedelta(minutes=5),
33 )
34 messages.append(
35 {"role": "tool", "tool_call_id": response.tool_call["id"], "content": result}
36 )
The only real change is that we now wrap the loop in a workflow, and the function calls become activity executions:
- 18-23: The model call runs as an activity with a 60-second timeout and a retry policy that runs the activity up to five times with exponential backoff. Temporal lets you set up better retry policies for retryable and non-retryable errors, which I also use frequently in my workflows.
- 29-33: Similarly, tools are executed in their own activity with a longer five-minute timeout (and default policies). The result of the tool run is in the event history, which can be replayed instead of running it again, which is quite useful for non-idempotent operations.
1package agent
2
3import (
4 "time"
5
6 "go.temporal.io/sdk/temporal"
7 "go.temporal.io/sdk/workflow"
8)
9
10func AgentWorkflow(ctx workflow.Context, goal string) (string, error) {
11 ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
12 StartToCloseTimeout: 60 * time.Second,
13 RetryPolicy: &temporal.RetryPolicy{
14 MaximumAttempts: 5,
15 },
16 })
17
18 messages := []Message{
19 {Role: "system", Content: systemPrompt},
20 {Role: "user", Content: goal},
21 }
22
23 for {
24 var response LLMResponse
25 err := workflow.ExecuteActivity(ctx, CallLLM, messages).Get(ctx, &response)
26 if err != nil {
27 return "", err
28 }
29 messages = append(messages, response.Message)
30
31 if len(response.ToolCalls) == 0 {
32 return response.Content, nil
33 }
34
35 toolCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
36 StartToCloseTimeout: 5 * time.Minute,
37 })
38 var result string
39 err = workflow.ExecuteActivity(toolCtx, RunTool, response.ToolCall).Get(ctx, &result)
40 if err != nil {
41 return "", err
42 }
43 messages = append(messages, Message{
44 Role: "tool",
45 ToolCallID: response.ToolCall.ID,
46 Content: result,
47 })
48 }
49}
The only real change is that we now wrap the loop in a workflow, and the function calls become activity executions:
- 11-16: A 60-second timeout and a retry policy that runs an activity up to five times with exponential backoff. Temporal lets you set up better retry policies for retryable and non-retryable errors, which I also use frequently in my workflows.
- 25: The model call runs as an activity under these options. On a transient failure, Temporal reruns it without the loop having to catch anything.
- 35-39: The tool runs in its own activity with a longer five-minute timeout (and the default retry policy). The result of the tool run is in the event history, which can be replayed instead of running it again, which is quite useful for non-idempotent operations.
With these slight modifications leveraging Temporal, you get retries with backoff and recovery out of the box, without having to reinvent a worse wheel yourself.
Server, Workers, and Client
Temporal has three components:
- Server: The dev server we started at the beginning. It stores the event history and distributes work to workers via a task queue.
- Workers: This is what runs your code. A worker picks up work from the server, executes the workflow and its activities, and reports the results. Workers are stateless, so you can spin up multiple workers to scale horizontally.
- Client: This starts a workflow. It just tells the server to start one and does not execute any of your code.
The client hands a workflow to the server, and any worker polling the same task queue (agent, in the example below) picks it up. For example, the client might sit inside your backend and start a workflow on a REST call, while one or more workers poll the server and execute the agent loop.
LLM + tool activities"] end client -->|"1 · start workflow"| server loop -->|"2 · poll task queue"| server server -->|"3 · deliver task"| loop loop -->|"4 · report result"| server server -.->|"5 · final result"| client
The dev server is already running. Let’s create the worker, which will register the workflow and activities, and then the client, which starts the workflow.
import asyncio
from concurrent.futures import ThreadPoolExecutor
from temporalio.client import Client
from temporalio.worker import Worker
from activities import call_llm, run_tool
from workflow import AgentWorkflow
async def main():
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="agent",
workflows=[AgentWorkflow],
activities=[call_llm, run_tool],
activity_executor=ThreadPoolExecutor(max_workers=10),
)
await worker.run()
if __name__ == "__main__":
asyncio.run(main())
package main
import (
"log"
"agent"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
)
func main() {
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("unable to create Temporal client:", err)
}
defer c.Close()
w := worker.New(c, "agent", worker.Options{})
w.RegisterWorkflow(agent.AgentWorkflow)
w.RegisterActivity(agent.CallLLM)
w.RegisterActivity(agent.RunTool)
if err := w.Run(worker.InterruptCh()); err != nil {
log.Fatalln("unable to start worker:", err)
}
}
Then start a run from a client:
import asyncio
from temporalio.client import Client
from workflow import AgentWorkflow
async def main():
client = await Client.connect("localhost:7233")
result = await client.execute_workflow(
AgentWorkflow.run,
"What should I pack for my flight QF1?",
id="pack-for-qf1",
task_queue="agent",
)
print(result)
if __name__ == "__main__":
asyncio.run(main())
package main
import (
"context"
"fmt"
"log"
"agent"
"go.temporal.io/sdk/client"
)
func main() {
c, err := client.Dial(client.Options{})
if err != nil {
log.Fatalln("unable to create Temporal client:", err)
}
defer c.Close()
run, err := c.ExecuteWorkflow(
context.Background(),
client.StartWorkflowOptions{ID: "pack-for-qf1", TaskQueue: "agent"},
agent.AgentWorkflow,
"What should I pack for my flight QF1?",
)
if err != nil {
log.Fatalln("unable to start workflow:", err)
}
var result string
if err := run.Get(context.Background(), &result); err != nil {
log.Fatalln("unable to get workflow result:", err)
}
fmt.Println(result)
}
With the dev server still running, start the worker and kick off a run:
python worker.py
python starter.py
go run ./worker
go run ./starter
You will see a similar answer from the agent as before. You can open the Temporal web UI at http://localhost:8233, and you will find this workflow run. Each LLM call and tool call shows up in the event history, and you will be able to click through to see exactly what happened in each step. This inherent observability is quite useful when debugging long-running agent workflows.
Each call_llm and run_tool is its own activity in the event history.
You can try building more complex, multi-step agents with more tools that could have ephemeral issues to see how Temporal helps with durability.
HIRING
I’m hiring software engineers and researchers to join our team at Eternis. Get in touch if you’d like to work with us.
For my team and me, all of our agent loops are, by default, in Temporal. We self-host the open source version of Temporal, and it seems to meet all our needs.
Temporal also translates well to multi-agent architectures. For example, Temporal has the concept of child workflows, which can correspond to child agents spawned by a main agent. Once you get used to Temporal, you will start to think about your agent architecture in terms of these familiar building blocks.


