{"slug": "building-an-agentic-loop-in-temporal", "title": "Building an Agentic Loop in Temporal", "summary": "Cadenya published a technical walkthrough detailing how its hosted agent runtime runs an agentic loop on Temporal, with the top-level `AgentObjectiveWorkflow` accepting a single `AgentObjectiveWorkflowInput` protocol buffer that wraps an `Objective`. The Go-based workflow loads an agent's configured tools, calls the inference provider with the system prompt and first user message, runs child workflows for tool calls, pops queued actions such as compaction and messages from Postgres, and starts a child workflow to compact a conversation and continue. The input message caps LLM iterations via `max_iterations` (0 means unlimited) and forwards `total_iterations`, `total_context_windows` and `total_tool_calls` across continue-as-new, with agent state mirrored from Temporal to Postgres alongside added states such as `interrupting`.", "body_md": "Get to know Cadenya\n\nWe’re developers who love to build. We set out to create a yes-code platform that makes building agents feel like the best parts of building software.\n\n# Building an Agentic Loop in Temporal\n\nWalkthrough\n\n``` bash\n$ whoami\n> Robert Ross\n$ pwd\n> Cambridge, MA\n```\n\nCadenya uses Temporal under the hood to power our users’ agents in our hosted runtime. This guide walks through how we run a Temporal AI agent: the workflows and activities behind it, and how they add up to an agentic loop. We’ll talk about parking an agent loop for a human-in-the-loop approval, queued messages, and triggering a compaction. Cadenya is written in Go, so all of the snippets in this post are close (but not exact) copies straight from our codebase. Strap in!\n\n## The Database Behind Our Temporal AI Agents\n\nCadenya stores agents, variations, and objectives in Postgres. A lot of our Temporal workflows and activities are a dance between database state and the loop. Much of Temporal’s “state” for workflows is mirrored to the database, with additional states like `interrupting` added. Our database ERD is below to show the (vast) relationships between tables.\n\n```\nerDiagram\n  agents ||--o{ objectives : \"\"\n  agent_variations ||--o{ objectives : \"\"\n  objectives ||--o{ objective_context_windows : \"\"\n  objectives |o--o| objective_context_windows : \"\"\n  objectives ||--o{ objective_events : \"\"\n  objectives ||--o{ objective_iterations : \"\"\n  objectives ||--o{ objective_tool_calls : \"\"\n  objectives ||--o{ objective_tools : \"\"\n  objectives ||--o{ objective_queued_actions : \"\"\n  objectives ||--o{ objective_secrets : \"\"\n  objectives ||--o{ objective_feedbacks : \"\"\n  objectives ||--o{ objective_webhook_deliveries : \"\"\n  objective_context_windows ||--o{ objective_events : \"\"\n  objective_context_windows ||--o{ objective_iterations : \"\"\n  objective_context_windows ||--o{ objective_tool_calls : \"\"\n  objective_context_windows ||--o{ objective_context_window_tools : \"\"\n  objective_tools ||--o{ objective_context_window_tools : \"\"\n  objective_events |o--o{ objective_tool_calls : \"\"\n  objective_events |o--o{ objective_queued_actions : \"\"\n  objective_events ||--o{ objective_webhook_deliveries : \"\"\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\n## The Agentic Loop as a Temporal Workflow\n\nAn agentic loop needs an entry point. Cadenya’s top-level Temporal workflow is called `AgentObjectiveWorkflow` and accepts a single argument, an `AgentObjectiveWorkflowInput` protocol buffer that wraps the `Objective`. For more on the API design, [read this blog post](https://cadenya.com/handwritten/designing-cadenyas-api).\n\nOur `AgentObjectiveWorkflow` is responsible for:\n\n1. Loading tools that are configured for the Agent that is being given an objective (e.g. refund a customer, book a trip, etc.)\n2. Executing an activity to call the inference provider with the system prompt and first user message\n3. Executing child workflows for tool calls\n4. Popping queued actions out from the database (compaction, messages)\n5. Starting a child workflow to compact a conversation and continue\n\nThe workflow’s function signature takes a single input that is a protocol buffer. The message is defined like:\n\n```\nmessage AgentObjectiveWorkflowInput {\n  // objective is the objective being executed.\n  cadenya.api.v1.Objective objective = 1;\n\n  // context_window is the initial context window for this execution.\n  cadenya.api.v1.ObjectiveContextWindow context_window = 2;\n\n  // max_iterations caps the total number of LLM iterations. 0 means unlimited.\n  int32 max_iterations = 3;\n\n  // total_iterations tracks iterations completed across all executions.\n  // Incremented during the loop and forwarded on continue-as-new.\n  int32 total_iterations = 4;\n\n  // total_context_windows tracks context windows used across executions.\n  int32 total_context_windows = 5;\n\n  // total_tool_calls tracks tool calls dispatched across all executions of\n  // this workflow, forwarded on continue-as-new. Compared against the\n  // variation's constraints.max_tool_calls to enforce the limit.\n  int32 total_tool_calls = 8;\n}\n```\n\nIn the Temporal UI, a single run of the workflow looks like this:\n\n### Heartbeats\n\nWe send heartbeats from a Temporal workflow called `ObjectiveHeartbeatWorkflow` that is *immediately* started as a child workflow when the `AgentObjectiveWorkflow` starts and then “backgrounded” so that the rest of the workflow can continue. Its job is very simple: send heartbeats to any attached SSE handler. It’s the simplest of our workflows:\n\n``` js\n// internal/objectiveworker/heartbeat_workflow.go\nconst heartbeatInterval = 5 * time.Second\nconst heartbeatPulsesPerRun = 100\n\nfunc ObjectiveHeartbeatWorkflow(ctx workflow.Context, input *objectiveworkflowsv1.ObjectiveHeartbeatWorkflowInput) (*objectiveworkflowsv1.ObjectiveHeartbeatWorkflowOutput, error) {\n\tactx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{\n\t\tScheduleToCloseTimeout: 5 * time.Second,\n\t\tRetryPolicy:            &temporal.RetryPolicy{MaximumAttempts: 1},\n\t})\n\n\tfor range heartbeatPulsesPerRun {\n\t\tif err := workflow.Sleep(ctx, heartbeatInterval); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tpulse := (&objectiveworkflowsv1.EmitObjectiveHeartbeatActivityInput_builder{\n\t\t\tObjectiveId: input.GetObjectiveId(),\n\t\t\tWorkspaceId: input.GetWorkspaceId(),\n\t\t\tCompaction:  input.GetCompaction(),\n\t\t}).Build()\n\n\t\tvar out objectiveworkflowsv1.EmitObjectiveHeartbeatActivityOutput\n\t\terr := workflow.ExecuteActivity(actx, persistence.Activity.EmitObjectiveHeartbeat, pulse).Get(ctx, &out)\n\t\tif ctx.Err() != nil {\n\t\t\treturn nil, ctx.Err()\n\t\t}\n\t\tif err != nil {\n\t\t\tworkflow.GetLogger(ctx).Warn(\"heartbeat activity failed\", \"error\", err)\n\t\t}\n\t\tif out.GetStop() {\n\t\t\treturn new(objectiveworkflowsv1.ObjectiveHeartbeatWorkflowOutput), nil\n\t\t}\n\t}\n\n\t// Continue as new so a long-running objective never grows this history.\n\treturn nil, workflow.NewContinueAsNewError(ctx, ObjectiveHeartbeatWorkflow, input)\n}\n```\n\nThese heartbeats are what land in any open server-sent event stream that is connected to the Cadenya API. You can learn more about our [SSE architecture from this post](https://cadenya.com/handwritten/the-architecture-behind-our-sse-streams).\n\n### Status Updates\n\nObjectives in Cadenya change state as a loop steps through the turns the LLM returns. When an objective is initially created, it starts in a `PENDING` state that quickly changes to `RUNNING` from the *first* activity in our workflow. Every event that an agent steps through is stored in our `objective_events` table and pushed to Google Cloud Pub/Sub for side effects like webhooks. Every single event in Cadenya can be received as a webhook, and instead of sending webhooks from the objective event, we use event-oriented architecture to separate the concern.\n\nSo at this point, our `AgentObjectiveWorkflow` has:\n\n1. Started the heartbeat workflow\n2. Updated the status of the objective\n\nThe status update activity:\n\n1. Starts a postgres transaction\n2. Basically performs an `UPDATE objectives SET status = 'running'`\n3. Inserts a new `objective_events` record to log that the state has indeed changed, and when.\n4. Inserts a `pubsub_outbox` postgres record.\n5. Commits the transaction\n\nThe activity itself doesn’t *actually* send the Google Pub/Sub message. Instead, all `objective_events` records are created in a transaction that also stores the raw message that will eventually land on our event broker. This happens using a `NOTIFY` and `LISTEN` in a separate worker on the table.\n\nThe complexity is necessary, because if the transaction fails, we’d never accidentally send a webhook. And if publishing the message after the record was created failed, we’d never fire the side effects of a new event.\n\n```\n// Flattened for this post: in our codebase the activity calls a repository,\n// which calls an event repository, which writes to the outbox.\nfunc (a *Activities) UpdateObjectiveStatus(ctx context.Context, input *objectiveworkflowsv1.UpdateObjectiveStatusActivityInput) error {\n\tto, _ := records.ObjectiveStateToEnt(input.GetState())\n\n\ttx, err := a.db.Tx(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer tx.Rollback()\n\n\t// Lock the objective row so concurrent transitions queue up behind this one.\n\trecord, err := tx.GetObjectiveClient().Query().\n\t\tWhere(objective.IDEQ(input.GetObjectiveId())).\n\t\tForUpdate().\n\t\tOnly(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// Temporal retries activities. A retry that finds the state already\n\t// applied does nothing, so we never write a duplicate event.\n\tif record.GetStatus() == to {\n\t\treturn nil\n\t}\n\t// A cancelled or failed objective can't be brought back, so don't retry.\n\tif objectives.IsTerminal(record.GetStatus()) {\n\t\treturn temporal.NewNonRetryableApplicationError(\"objective has already ended\", \"ObjectiveEnded\", nil)\n\t}\n\n\tupdated, err := tx.GetObjectiveClient().UpdateOneID(record.GetID()).\n\t\tSetStatus(to).\n\t\tSetStatusMessage(input.GetMessage()).\n\t\tSave(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// The event that says \"this objective went from PENDING to RUNNING\".\n\tdata := (&apiv1.ObjectiveEventData_builder{\n\t\tStateChanged: (&apiv1.ObjectiveStateChanged_builder{\n\t\t\tFromState: records.ObjectiveStateFromEnt(record.GetStatus()),\n\t\t\tToState:   input.GetState(),\n\t\t\tMessage:   input.GetMessage(),\n\t\t}).Build(),\n\t}).Build()\n\n\tevent, err := tx.GetObjectiveEventClient().Create().\n\t\tSetObjectiveID(updated.GetID()).\n\t\tSetObjectiveContextWindowID(*updated.GetCurrentContextWindowID()).\n\t\tSetEventType(objectiveevent.EventTypeObjectiveStateChanged).\n\t\tSetEvent(data.DatabaseValue()).\n\t\tSave(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// The Pub/Sub message is stored, not sent. It commits or rolls back with\n\t// the status and the event, and a separate relay publishes it afterwards.\n\tobjectivePb, _ := records.ObjectivePb(updated)\n\teventPb, _ := records.ObjectiveEventPb(event)\n\tmessage := (&topicsv1.ObjectiveEventCreated_builder{\n\t\tObjective: objectivePb,\n\t\tEvent:     eventPb,\n\t}).Build()\n\tpayload, err := proto.Marshal(message)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\terr = tx.GetPubsubOutboxClient().Create().\n\t\tSetTopic(pubsub.TopicName(message)).\n\t\tSetData(payload).\n\t\tSetAttributes(map[string]string{\"event_type\": string(event.GetEventType())}).\n\t\tExec(ctx)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn tx.Commit()\n}\n```\n\n### Inference as a Temporal Activity\n\nAfter the objective state and event are stored (which takes < 100ms), we kick off inference in a separate activity called `Infer`. This load-bearing activity has a long list of things it needs:\n\n1. Pull the AI Provider details from the agent’s variation (OpenAI, Anthropic, Gemini, etc.)\n2. Read the tools for the current context window from the database and convert them into the appropriate JSON Schema\n3. Start a stream so we can send completion deltas from inference to connected SSE streams\n4. Start another postgres transaction to store Reasoning and Assistant events separately.\n5. Store any `toolCalls` that the LLM would like for us to run\n6. Pubsub Outbox committed and relayed. So much pubsub. We love pubsub.\n\nNote: Cadenya heavily relies on the [Open-Closed Principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle) to abstract away how AI Providers work. This religion makes sure we don’t end up having `InferOpenAI` nonsense littered around the application code.\n\n```\nsequenceDiagram\n  participant W as AgentObjectiveWorkflow\n  participant I as Infer activity\n  participant DB as Postgres\n  participant P as AI Provider\n  participant S as SSE streams\n\n  W->>I: objective ID, context window ID\n  I->>DB: Load objective, check the account can run a loop\n  I->>DB: Load context window, tools, model, provider credentials\n  I->>DB: Load the conversation so far\n  I->>P: Chat completion request with tools as JSON Schema\n  loop While the provider streams\n      P-->>I: Completion delta\n      I-->>S: Completion delta\n  end\n  P-->>I: Final response and token usage\n  I->>DB: One transaction for the assistant event and its tool calls\n  I-->>W: Assistant event ID, tool call IDs, stop reason, usage\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\nBecause completion API calls to model providers are stateless, we need to send the entire context window’s conversation for inference. Cadenya does *not* store tool result content in Postgres. Instead, as we’ll learn in the next section, those details are loaded from Redis/GCS from our inference activity.\n\nThe Protocol Buffer for the `InferInput` and `InferOutput` is dead simple:\n\n```\n// InferInput identifies the objective window and iteration receiving a response.\nmessage InferInput {\n  string objective_id = 1 [(buf.validate.field).string.min_len = 1];\n  string context_window_id = 2 [(buf.validate.field).string.min_len = 1];\n  string iteration_id = 3 [(buf.validate.field).string.min_len = 1];\n}\n\n// InferOutput carries references only; response payloads live in the database.\nmessage InferOutput {\n  string assistant_event_id = 1;\n  repeated string tool_call_ids = 2;\n  StopReason stop_reason = 3;\n\n  // usage is the provider's token count for this call. The workflow derives\n  // how full the context window is from it to decide when to compact.\n  TokenUsage usage = 4;\n}\n```\n\nAnd the activity that uses these messages looks like:\n\n```\n// Every provider (OpenAI, Anthropic, Gemini, and so on) implements this one\n// interface. It is all the Infer activity knows about a provider.\ntype ChatCompleter interface {\n\tChatCompletion(ctx context.Context, req *llmv1.ChatCompletionRequest) (*llmv1.ChatCompletionResponse, error)\n}\n\n// Simplified from internal/objectiveworker/inference/infer.go. The helpers\n// on `a` stand in for a few dozen lines of loading and bookkeeping each.\nfunc (a *Activities) Infer(ctx context.Context, input *workflowpb.InferInput) (*workflowpb.InferOutput, error) {\n\t// Tell Temporal this activity is alive while the model thinks.\n\tdefer activityheartbeat.Start(ctx)()\n\n\t// Load the objective and make sure the account can still run a loop.\n\t// Checked before the provider is called, so a refused loop costs nothing.\n\tobjective, err := a.loadObjective(ctx, input.GetObjectiveId())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif err := a.requireLoopBudget(ctx, objective); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Rebuild the request for this context window: the system prompt, every\n\t// message, tool call, and tool result so far, plus the tools the model\n\t// may call. Postgres holds the order of the conversation. Tool result\n\t// content is read from Redis first and falls back to GCS.\n\trequest, err := a.loadConversation(ctx, objective, input.GetContextWindowId())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Look up the model on the agent's variation and get the ChatCompleter\n\t// for its provider. This is the only line that knows which provider it is.\n\tcompleter, err := a.completerFor(ctx, objective, request)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Call the model. Each streamed chunk goes straight out to any connected\n\t// SSE stream. Chunks are ephemeral and are never written to Postgres.\n\tresponse, err := aiproviders.StreamChatCompletion(ctx, completer, request, func(delta *llmv1.ChatCompletionDelta) {\n\t\ta.publishDelta(ctx, objective, delta)\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// One transaction: the reasoning and assistant events, a row for every\n\t// tool call the model asked for, and the iteration's token usage.\n\t//\n\t// What comes back is references only: the assistant event ID, tool call\n\t// IDs, stop reason, and usage. The conversation never enters Temporal's\n\t// workflow history.\n\treturn a.recordResponse(ctx, objective, input, response)\n}\n```\n\nAs a principle, we do *not* return any LLM or conversation details to Temporal’s workflow history. Security is the primary reason (despite going through a codec). The second is cost: storing more workflow history costs more on Temporal Cloud. The third is that a larger workflow history size means we’d need more `ContinueAsNew` Temporal commands in the agent workflow.\n\nAn example of what the `objective_events` table looks like after this activity fires would be:\n\n``` js\n=> SELECT id, objective_context_window_id, seq, event_type, event, started_at, duration_ms, created_at\n   FROM objective_events\n   WHERE objective_id = 'obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ' AND seq > 2\n   ORDER BY seq;\n\n-[ RECORD 1 ]---------------+------------------------------------------\nid                          | objevt_01M7DFFC2Q4H8N6R0XWJ5T9KAB\nobjective_context_window_id | objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD\nseq                         | 3\nevent_type                  | reasoning\nevent                       | \\x8a01530a51546865207573657220776173206368...\nstarted_at                  | 2026-11-14 16:50:28.712+00\nduration_ms                 | 842\ncreated_at                  | 2026-11-14 16:50:29.914+00\n-[ RECORD 2 ]---------------+------------------------------------------\nid                          | objevt_01M7DFFC2R7YV3M1DG8SZP6QEC\nobjective_context_window_id | objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD\nseq                         | 4\nevent_type                  | assistant_message\nevent                       | \\x12470a2b4c6574206d65206c6f6f6b2075702074...\nstarted_at                  |\nduration_ms                 |\ncreated_at                  | 2026-11-14 16:50:29.914+00\n```\n\nAnd the `objective_tool_calls` table (if the LLM wants to use a tool):\n\n``` js\n=> SELECT id, objective_event_id, llm_tool_call_id, function_name, arguments, status, execution_status, tool_id, started_at, ended_at, created_at\n   FROM objective_tool_calls\n   WHERE objective_id = 'obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ';\n\n-[ RECORD 1 ]------+------------------------------------------\nid                 | toolcall_01M7DFFC2RB6E0A9YQ4M8VJ7HK\nobjective_event_id | objevt_01M7DFFC2R7YV3M1DG8SZP6QEC\nllm_tool_call_id   | call_9fK2mQx7Ld0aTn4VbR8sWzYe\nfunction_name      | lookup_order\narguments          | {\"order_id\": \"ORD-48213\"}\nstatus             | auto_approved\nexecution_status   | pending\ntool_id            | tool_01M6ZK3P8W5N2T7QHB4XC9D0GA\nstarted_at         |\nended_at           |\ncreated_at         | 2026-11-14 16:50:29.914+00\n```\n\nOnce the `Infer` activity is done, the outer `AgentObjectiveWorkflow` takes back over, and the agentic loop decides what needs to happen next based on the stop reason in the `InferOutput` from above.\n\n### Tool Calls as Child Workflows\n\nAn agent runtime would be useless if it didn’t call tools. That’s like… the whole point. The `AgentObjectiveWorkflow` reads the return result of our `Infer` activity and if the `toolCallIds` is set with any values, we’ll execute each tool in its own future from the workflow. This Temporal feature allows the agent to execute multiple tools in parallel.\n\nCadenya implements a separate Temporal workflow called `RunToolCall` that receives a single input of `toolCallId` that pulls the *Cadenya* tool call ID (not the one the LLM generates) from the `objective_tool_calls` table that the `Infer` activity stored. The `tool_id` on the record is how we discern which adapter to use for the tool call itself (MCP, HTTP, etc.). This is where you should probably see what that database structure looks like:\n\n```\nerDiagram\n  tool_sets ||--o{ tools : \"\"\n  tools ||--o{ objective_tool_calls : \"\"\n\n  objective_tool_calls {\n      string id PK\n      string tool_id FK\n      string function_name\n      jsonb arguments\n      enum status\n      enum execution_status\n  }\n\n  tools {\n      string id PK\n      string tool_set_id FK\n      bytea spec \"name, description, parameters\"\n      bool requires_approval\n  }\n\n  tool_sets {\n      string id PK\n      bytea spec \"adapter: MCP, OpenAPI, HTTP, or Bare\"\n      bool just_in_time\n  }\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\nThe `RunToolCall` workflow is a dance of updating states of the tool calls, figuring out if it’s a `BUILTIN` or `EXTERNAL` tool (i.e. is this a tool Cadenya supplies, or is it from the customer?), and deciding whether it needs approval. It’s a big ol’ logistical plan that needs to be generated before we just start running `tool/call` requests to MCP servers. Hence the `PlanToolCall` activity that the `RunToolCall` workflow executes.\n\nAfter the plan is generated, the Temporal workflow executes the `StartToolCall` activity. Tool call states are also stored as new `objective_events` records in postgres for when they start and complete. This is where we steal the prior art of the Objective status that starts a new postgres transaction, commits, and sends the side effect via our outbox relay.\n\nThe plan result is used to steer the rest of the `RunToolCall`’s behavior.\n\n#### Basic: No Approval Needed\n\nIn the simplest scenario (just call the tool), we only need to change the tool’s state, execute the adapter (e.g. MCP) for the requested tool, and store the result of the tool call.\n\n1. Load the tool call from `objective_tool_calls` using the ID passed to the activity.\n2. Load the tool definition from the `tools` table (using the foreign key from the`objective_tool_calls` record)\n3. Resolve the adapter we need to call (MCP, Secrets, Headers, etc.)\n4. Call the tool\n5. Store the result of the tool call in Google Cloud Storage, under a key built by convention from the objective ID and the tool call ID\n6. The next time the conversation is loaded for inference, cache the result with a short TTL in Redis (so loading conversation state is *much* faster after that first read)\n\n```\nsequenceDiagram\n  participant W as RunToolCall workflow\n  participant A as Activity\n  participant DB as Postgres\n  participant T as Tool (MCP server)\n  participant G as Cloud Storage\n\n  W->>A: PlanToolCall with the tool call ID\n  A->>DB: Load the tool call, its tool, and its tool set\n  A-->>W: Plan with adapter kind and no approval needed\n\n  W->>A: StartToolCall with the tool call ID\n  A->>DB: Mark the tool call as running\n  A-->>W: Done\n\n  W->>A: ExecuteStoredToolCall with the tool call ID\n  A->>DB: Resolve the adapter, secrets, and headers\n  A->>T: Call the tool\n  T-->>A: Result content\n  A->>G: Store the result content under a key built by convention\n  A-->>W: Done, with no payload\n\n  W->>A: SettleToolCall with the tool call ID\n  A->>DB: Mark the tool call completed and write its events\n  A-->>W: Done\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\nCadenya has a clever pattern where a tool call can return a list of *other* tools that should be added to the context on the next agent turn. This is what powers progressive discovery, and in cases where the tool result is *hella* long, we return a directive to load a built-in Cadenya tool called `read_tool_result`. This prevents sending a 500k token input because a tool result was enormous. And it saves your budget.\n\n#### Advanced: Approving a Tool\n\nWhat about when a tool needs to be approved, though? This is where that `Plan` activity comes in handy, because it can change the shape of the workflow that needs to wait for a human to approve a tool.\n\nHere’s a Temporal workflow for a `RunToolCall` that requires an approval:\n\nI’m sure people will tell me “You should use Workflow update handlers for approvals”, but my mental model prefers the Async Activity pattern that Temporal provides. The `AwaitToolApproval` activity stores the async token, fires the objective event, and then lets Temporal’s machinery “park” the activity.\n\n```\n// Flattened for this post, and showing only the human approval path.\nfunc (a *Activities) AwaitToolApproval(ctx context.Context, ref *workflowpb.ToolCallReference) (*workflowpb.VetToolCallActivityOutput, error) {\n\tctx, row, in, err := toolcallref.Load(ctx, a.db, ref)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// A retry after the decision was already made just reports it.\n\tif row.GetStatus() == objectivetoolcall.StatusApproved || row.GetStatus() == objectivetoolcall.StatusDenied {\n\t\treturn (&workflowpb.VetToolCallActivityOutput_builder{\n\t\t\tApproved: row.GetStatus() == objectivetoolcall.StatusApproved,\n\t\t\tMemo:     row.GetMemo(),\n\t\t}).Build(), nil\n\t}\n\n\t// Tell the world we're waiting. This is an objective event like any\n\t// other, so it goes through the outbox and out as a webhook.\n\tdata := (&apiv1.ObjectiveEventData_builder{\n\t\tToolApprovalRequested: (&apiv1.ToolApprovalRequested_builder{\n\t\t\tToolCallId: row.GetID(),\n\t\t}).Build(),\n\t}).Build()\n\t_, err = a.eventRepo.CreateEvent(ctx, in.GetObjective(), (&apiv1.ObjectiveEvent_builder{\n\t\tContextWindowId: in.GetContextWindowId(),\n\t\tData:            data,\n\t}).Build())\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Every running activity has a task token. Whoever holds it can finish\n\t// this activity later, from anywhere, so we put it on the tool call row.\n\t_, err = a.db.GetObjectiveToolCallClient().UpdateOneID(row.GetID()).\n\t\tSetTaskToken(activity.GetInfo(ctx).TaskToken).\n\t\tSetApprovalRequestedAt(time.Now()).\n\t\tSave(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Not an error. This tells Temporal \"I'm not done, and I'm not coming\n\t// back\". The worker is freed and the activity stays open, in our case\n\t// for up to 24 hours.\n\treturn nil, activity.ErrResultPending\n}\n```\n\nThen, in our API for Cadenya, we have a service that can mark the approval of a tool call:\n\n```\n// The other half lives in our API, behind the approve endpoint. It reads the\n// task token back off the row and completes the parked activity with the\n// decision, which becomes AwaitToolApproval's return value in the workflow.\nfunc (r *repositoryDB) ApproveToolCall(ctx context.Context, req *apiv1.ApproveToolCallRequest) error {\n\trecord, err := r.getPendingToolCall(ctx, req.GetToolCallId())\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdecision := (&objectiveworkflowsv1.VetToolCallActivityOutput_builder{\n\t\tApproved:  true,\n\t\tProfileId: proto.String(servercontext.GetActor(ctx).ProfileID),\n\t}).Build()\n\n\treturn r.temporal.CompleteActivity(ctx, *record.TaskToken, decision, nil)\n}\n```\n\nThis uses the [Asynchronous Activity Completion](https://docs.temporal.io/develop/go/activities/asynchronous-activity) API, and the result that is set using this is then returned to the outer `RunToolCall` workflow.\n\nThe entire sequence looks like this:\n\n```\nsequenceDiagram\n  participant W as RunToolCall workflow\n  participant A as Activity\n  participant DB as Postgres\n  participant T as Temporal\n  participant API as Cadenya API\n  participant H as Human\n\n  W->>A: PlanToolCall with the tool call ID\n  A-->>W: Plan says approval is required\n\n  W->>A: AwaitToolApproval with the tool call ID\n  A->>DB: Write the approval requested event (webhook goes out)\n  A->>DB: Store the activity task token on the tool call\n  A-->>T: Result pending, the activity stays open\n\n  Note over W,T: Parked for up to 24 hours with no worker held\n\n  H->>API: Approve the tool call\n  API->>DB: Read the task token from the tool call\n  API->>T: Complete the activity with the decision\n  T-->>W: AwaitToolApproval returns the decision\n\n  W->>A: RecordStoredApproval with the decision\n  A->>DB: Set the tool call to approved\n  A->>DB: Write the approved event (webhook goes out)\n  A-->>W: Done\n\n  Note over W,A: StartToolCall, ExecuteStoredToolCall, SettleToolCall as before\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\n### Queuing Messages for a Running Agent\n\nA good agent runtime should allow users (machine or otherwise) to steer the agent by sending it messages. Waiting for the agent to complete its current turn, though, is a bad experience. Ideally, you should be able to queue messages the agent can pick up after its current turn is complete. This is a simple idea, but comes with complications. How do you make sure the agent always gets the queued messages? How do you ensure you don’t interrupt the current iteration and reorder the conversation history mistakenly? So many problems.\n\nCadenya solves this with a separate postgres table called `objective_queued_actions`. When a message arrives, the API locks the objective’s row and looks at its status:\n\n- If the objective is `waiting` , there is no workflow running. The message is appended directly to`objective_events` , the objective moves to`pending` , and an`ObjectiveContinued` message is written to the outbox, all in one transaction. The consumer of that message starts a new`AgentObjectiveWorkflow` .\n- If the objective is busy (`pending` ,`running` , or`interrupting` ) and the request set`enqueue` , the message is inserted into`objective_queued_actions` with a status of`queued` . Without`enqueue` , the request is refused.\n\n``` js\n=> \\d objective_queued_actions\n                                        Table \"public.objective_queued_actions\"\n       Column       |              Type              | Collation | Nullable |                 Default\n--------------------+--------------------------------+-----------+----------+------------------------------------------\n id                 | text                           |           | not null |\n account_id         | text                           |           | not null |\n workspace_id       | text                           |           | not null |\n objective_id       | text                           |           | not null |\n type               | objective_queued_action_type   |           | not null |\n status             | objective_queued_action_status |           | not null | 'queued'::objective_queued_action_status\n data               | bytea                          |           | not null |\n profile_id         | text                           |           | not null |\n objective_event_id | text                           |           |          |\n sent_at            | timestamp with time zone       |           |          |\n claim_id           | text                           |           |          |\n created_at         | timestamp with time zone       |           | not null | now()\n updated_at         | timestamp with time zone       |           | not null | now()\nIndexes:\n    \"objective_queued_actions_pkey\" PRIMARY KEY, btree (id)\n    \"idx_objective_queued_actions_objective_id_claim_id\" btree (objective_id, claim_id) WHERE claim_id IS NOT NULL\n    \"idx_objective_queued_actions_objective_id_status\" btree (objective_id, status)\nForeign-key constraints:\n    \"objective_queued_actions_objective_event_id_fkey\" FOREIGN KEY (objective_event_id) REFERENCES objective_events(id) ON DELETE SET NULL\n    \"objective_queued_actions_objective_id_fkey\" FOREIGN KEY (objective_id) REFERENCES objectives(id) ON DELETE CASCADE\n    \"objective_queued_actions_profile_id_fkey\" FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE\n```\n\nThe running workflow picks queued messages up with a `ClaimQueuedActions` activity at the boundary between turns, never in the middle of one. The claim takes the same lock on the objective’s row, creates the `objective_events` record for each message, and marks the queued action as `sent`. Because the message only enters the history at a turn boundary, it can never land between an assistant’s tool call and that tool’s result.\n\nAfter a message is queued, the API also sends the workflow an `action_queued` signal once the transaction commits. The signal is best effort and only cuts a pause short, like an agent sitting in a `wait` tool call. If the signal is lost, the message is still claimed at the next turn boundary.\n\nThe last piece is what guarantees a message is never stranded. When a turn ends, the workflow claims one more time, and if nothing is queued, the objective is set to `waiting` in that same transaction. A message is either queued before that claim and picked up by it, or it arrives after and finds a `waiting` objective. The flow looks like this:\n\n```\nsequenceDiagram\n  participant U as User\n  participant API as Cadenya API\n  participant DB as Postgres\n  participant W as AgentObjectiveWorkflow\n\n  U->>API: Send a message to the objective\n  API->>DB: Lock the objective row and read its status\n\n  alt Objective is waiting\n      API->>DB: Append the user message to objective_events\n      API->>DB: Set the objective to pending and write an outbox message\n      DB-->>W: The ObjectiveContinued consumer starts a new workflow run\n  else Objective is busy with a turn and the request asked to enqueue\n      API->>DB: Insert into objective_queued_actions as queued\n      API-->>W: Signal action_queued after the commit, best effort\n      Note over W: The current Infer and tool calls finish first\n      W->>DB: ClaimQueuedActions at the turn boundary\n      DB-->>W: Message moved into objective_events, action marked sent\n      W->>W: Next Infer sees the message\n  end\n\n  Note over DB,W: At the end of a turn the workflow claims once more\n  W->>DB: ClaimQueuedActions, parking if nothing is queued\n  DB-->>W: Nothing queued, objective set to waiting in the same transaction\n```\n\nThe diagram could not be loaded. Its source is shown above.\n\nThe `ClaimQueuedActions` activity is simple enough, in that it returns directions back to the workflow on whether or not it should enqueue another inference call:\n\n```\n// Flattened for this post: the activity is a thin wrapper around this.\nfunc (a *Activities) ClaimQueuedActions(ctx context.Context, input *objectiveworkflowsv1.ClaimQueuedActionsInput) (*objectiveworkflowsv1.ClaimQueuedActionsOutput, error) {\n\ttx, err := a.db.Tx(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tdefer tx.Rollback()\n\n\t// The same row lock the API takes before it queues a message, so a\n\t// message is queued either before this claim or after it. Never during.\n\trecord, err := tx.GetObjectiveClient().Query().\n\t\tWhere(objective.IDEQ(input.GetObjectiveId())).\n\t\tForUpdate().\n\t\tOnly(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Temporal retries activities. If an earlier attempt of this exact claim\n\t// already committed, hand back the same batch instead of taking another.\n\tclaimed, err := tx.GetObjectiveQueuedActionClient().Query().\n\t\tWhere(\n\t\t\tobjectivequeuedaction.ObjectiveIDEQ(record.GetID()),\n\t\t\tobjectivequeuedaction.ClaimIDEQ(input.GetClaimId()),\n\t\t).\n\t\tOrder(objectivequeuedaction.ByID(sql.OrderAsc())).\n\t\tAll(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tif len(claimed) > 0 {\n\t\treturn claimedBatch(claimed)\n\t}\n\n\t// Everything still queued for this objective, oldest first.\n\tqueued, err := tx.GetObjectiveQueuedActionClient().Query().\n\t\tWhere(\n\t\t\tobjectivequeuedaction.ObjectiveIDEQ(record.GetID()),\n\t\t\tobjectivequeuedaction.StatusEQ(objectivequeuedaction.StatusQueued),\n\t\t).\n\t\tOrder(objectivequeuedaction.ByID(sql.OrderAsc())).\n\t\tAll(ctx)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// A batch stops at a queued compaction, so messages sent after it join\n\t// the compacted window instead of being summarized away.\n\tbatch := batchOf(queued)\n\n\t// Nothing queued. At the end of a turn the workflow asks to park, and the\n\t// objective moves to WAITING in this same transaction.\n\tif len(batch) == 0 {\n\t\tout := new(objectiveworkflowsv1.ClaimQueuedActionsOutput)\n\t\tif input.GetPark() {\n\t\t\tif err := a.transitionStatus(ctx, tx, record, objective.StatusWaiting, \"Waiting\"); err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tout.SetParked(true)\n\t\t}\n\t\treturn out, tx.Commit()\n\t}\n\n\t// Deliver the batch: each message becomes a real objective event in the\n\t// current context window, and its queued action is marked sent.\n\tfor i, action := range batch {\n\t\tupdate := tx.GetObjectiveQueuedActionClient().UpdateOne(action).\n\t\t\tSetStatus(objectivequeuedaction.StatusSent).\n\t\t\tSetSentAt(time.Now()).\n\t\t\tSetClaimID(input.GetClaimId())\n\n\t\t// A compaction has no event. It is returned for the workflow to run.\n\t\tif data := conversationEventData(action.GetData().Message); data != nil {\n\t\t\tevent, err := a.eventRepo.CreateEvent(ctx, record, (&apiv1.ObjectiveEvent_builder{\n\t\t\t\tContextWindowId: input.GetContextWindowId(),\n\t\t\t\tData:            data,\n\t\t\t}).Build())\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tupdate.SetObjectiveEventID(event.GetID())\n\t\t}\n\n\t\tif batch[i], err = update.Save(ctx); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\tif err := tx.Commit(); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// The directions for the workflow: how many messages were delivered (any\n\t// at all means run another turn) and whether a compaction should run.\n\treturn claimedBatch(batch)\n}\n```\n\n### Compacting a Long-Running Agentic Loop\n\nLong-horizon tasks for an AI Agent require a compaction feature at the runtime level. In Cadenya, compaction is triggered after a number of tokens is reached and crosses the threshold configured in the Agent variation. Using the `TokenUsage` that is returned from the `Infer` activity, our outer loop in the `AgentObjectiveWorkflow` calculates whether the threshold has been crossed, and schedules a new `CompactContextWindowWorkflow` child workflow.\n\nThis workflow reads the current conversation state, sends a separate LLM completion request, and stores the output as the first message in a new `objective_context_windows` record that is assigned to the `objectives` record. The `objective_events` table has a foreign key that associates the events to the window, which allows Cadenya to show every event in a single timeline, while only sending messages for the current window to the LLM to continue long-horizon tasks.\n\nFor example, the table results might look like this:\n\n``` js\n=> SELECT id, status, current_context_window_id FROM objectives\n   WHERE id = 'obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ';\n\n               id               | status  |     current_context_window_id\n--------------------------------+---------+-----------------------------------\n obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ | running | objwin_01M7DG4T6VQ0E8M3YK5RHB2NWZ\n(1 row)\n\n=> SELECT id, sequence, status FROM objective_context_windows\n   WHERE objective_id = 'obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ' ORDER BY sequence;\n\n                 id                | sequence |   status\n-----------------------------------+----------+-----------\n objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD |        1 | published\n objwin_01M7DG4T6VQ0E8M3YK5RHB2NWZ |        2 | published\n(2 rows)\n\n=> SELECT seq, objective_context_window_id, event_type FROM objective_events\n   WHERE objective_id = 'obj_01M7DFFB8EPVD6JTTEMRZ7ZXMZ' AND seq > 83 ORDER BY seq;\n\n seq |    objective_context_window_id    |        event_type\n-----+-----------------------------------+--------------------------\n  84 | objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD | reasoning\n  85 | objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD | assistant_message\n  86 | objwin_01M7DFFB8FJ2K9W4C7NHX3T5RD | tool_result\n  87 | objwin_01M7DG4T6VQ0E8M3YK5RHB2NWZ | context_window_compacted\n  88 | objwin_01M7DG4T6VQ0E8M3YK5RHB2NWZ | user_message\n  89 | objwin_01M7DG4T6VQ0E8M3YK5RHB2NWZ | assistant_message\n(6 rows)\n```\n\nWhen reconstructing history to send to an LLM provider, the query always filters on the context window ID and the `event_type` enum, making it easy to find *only* the events that are needed to reconstruct the current window.\n\n## The Whole Agentic Loop in One Temporal Workflow\n\nEvery piece above hangs off a single loop. Here is the `AgentObjectiveWorkflow` with all of those pieces in place, trimmed down to the part that matters. I wrote this version for the post, so it skips interrupts, cancellation, iteration and tool call limits, and structured output. The shape is the real thing.\n\n``` js\nconst iterationsPerExecution = 25\n\nfunc AgentObjectiveWorkflow(ctx workflow.Context, input *objectiveworkflowsv1.AgentObjectiveWorkflowInput) (*objectiveworkflowsv1.AgentObjectiveWorkflowOutput, error) {\n\tobjective := input.GetObjective()\n\twindow := input.GetContextWindow()\n\n\t// Heartbeats run next to everything else until this run returns.\n\tstopHeartbeat := startObjectiveHeartbeat(ctx, objective)\n\tdefer stopHeartbeat()\n\n\t// PENDING to RUNNING: the status, its event, and the outbox message.\n\tif err := updateStatus(ctx, objective, apiv1.ObjectiveState_OBJECTIVE_STATE_RUNNING); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Model calls run on their own task queue, away from the orchestration.\n\tinferCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{\n\t\tTaskQueue:           taskqueues.ObjectiveInference,\n\t\tStartToCloseTimeout: 10 * time.Minute,\n\t\tHeartbeatTimeout:    5 * time.Second,\n\t\tRetryPolicy:         &temporal.RetryPolicy{MaximumAttempts: 5},\n\t})\n\n\tvar err error\n\tfor turn := range iterationsPerExecution {\n\t\t// Between turns: pull in anything queued while the agent was busy.\n\t\t// A queued compaction runs here too, and hands back the new window.\n\t\twindow, _, err = claimQueuedActions(ctx, objective, window, neverPark)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\t// One model call. Only references come back into workflow history.\n\t\tvar inferred objectiveworkflowsv1.InferOutput\n\t\terr = workflow.ExecuteActivity(inferCtx, inference.Activity.Infer, (&objectiveworkflowsv1.InferInput_builder{\n\t\t\tObjectiveId:     objective.GetMetadata().GetId(),\n\t\t\tContextWindowId: window.GetMetadata().GetId(),\n\t\t\tIterationId:     fmt.Sprintf(\"agent-loop-%d\", turn),\n\t\t}).Build()).Get(ctx, &inferred)\n\t\tif err != nil {\n\t\t\treturn nil, failObjective(ctx, objective, err)\n\t\t}\n\n\t\t// The model asked for tools. Start one child workflow per tool call,\n\t\t// then wait for all of them, so they run in parallel.\n\t\tif inferred.GetStopReason() == objectiveworkflowsv1.StopReason_STOP_REASON_TOOL_CALLS {\n\t\t\tvar toolCalls []workflow.ChildWorkflowFuture\n\t\t\tfor _, id := range inferred.GetToolCallIds() {\n\t\t\t\tref := (&objectiveworkflowsv1.ToolCallReference_builder{ToolCallId: id}).Build()\n\t\t\t\ttoolCalls = append(toolCalls, workflow.ExecuteChildWorkflow(ctx, RunToolCall, ref))\n\t\t\t}\n\t\t\tfor _, toolCall := range toolCalls {\n\t\t\t\tif err := toolCall.Get(ctx, nil); err != nil {\n\t\t\t\t\treturn nil, failObjective(ctx, objective, err)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// The window is getting full. Summarize it and carry on in a new one.\n\t\tif compaction.ShouldCompact(objective, inferred.GetUsage(), inferred.GetMaxInputTokens()) {\n\t\t\tvar compacted objectiveworkflowsv1.CompactContextWindowWorkflowOutput\n\t\t\terr = workflow.ExecuteChildWorkflow(ctx, CompactContextWindowWorkflow, (&objectiveworkflowsv1.CompactContextWindowWorkflowInput_builder{\n\t\t\t\tObjective:     objective,\n\t\t\t\tContextWindow: window,\n\t\t\t}).Build()).Get(ctx, &compacted)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, failObjective(ctx, objective, err)\n\t\t\t}\n\t\t\twindow = compacted.GetNewWindow()\n\t\t}\n\n\t\t// The model is done talking. Claim one last time: if a message was\n\t\t// queued during the turn, answer it. If not, the same transaction\n\t\t// parks the objective in WAITING and this run is over.\n\t\tif inferred.GetStopReason() == objectiveworkflowsv1.StopReason_STOP_REASON_END_TURN {\n\t\t\tvar delivered int32\n\t\t\twindow, delivered, err = claimQueuedActions(ctx, objective, window, parkWhenIdle)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tif delivered == 0 {\n\t\t\t\treturn new(objectiveworkflowsv1.AgentObjectiveWorkflowOutput), nil\n\t\t\t}\n\t\t}\n\t}\n\n\t// Still going after 25 turns. Start a fresh run with an empty history\n\t// and pick up from the current context window.\n\tinput.SetContextWindow(window)\n\treturn nil, workflow.NewContinueAsNewError(ctx, AgentObjectiveWorkflow, input)\n}\n```\n\nOk, deep breath. Not that bad, right?\n\n## Wrapping Up\n\nTemporal is an incredible piece of infrastructure to build agentic platforms with, and every Temporal AI agent we run leans on the same handful of workflows. Cadenya’s opinionated runtime leverages it to durably run workloads, enable human-in-the-loop approvals, and trigger compaction.\n\nAll you need to do now is point Cadenya at an MCP or API server, and watch it handle the entire agentic loop for you.\n\n## Grow wherever AI goes next.\n\nStart shipping agents that are equipped to evolve.", "url": "https://wpnews.pro/news/building-an-agentic-loop-in-temporal", "canonical_source": "https://cadenya.com/handwritten/agentic-loop-in-temporal", "published_at": "2026-11-14 19:59:07+00:00", "updated_at": "2026-11-14 20:23:50.314010+00:00", "lang": "en", "topics": ["ai-agents", "artificial-intelligence", "ai-tools"], "entities": ["Cadenya", "Temporal", "Robert Ross", "AgentObjectiveWorkflow", "AgentObjectiveWorkflowInput", "Postgres", "Go"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/building-an-agentic-loop-in-temporal", "markdown": "https://wpnews.pro/news/building-an-agentic-loop-in-temporal.md", "text": "https://wpnews.pro/news/building-an-agentic-loop-in-temporal.txt", "jsonld": "https://wpnews.pro/news/building-an-agentic-loop-in-temporal.jsonld"}}