Directual
Menu

An AI Agent on Directual Without a Home-Made Loop: Plugging In theloop

November 11, 2026

The "model → tools → model" agent loop is awkward to build out of scenario steps: retries, streaming, limits and token accounting quickly turn it into a mess. Here is a setup where the loop runs in theloop, an open-source stateless HTTP service, while Directual stays the orchestrator and owns history, data, permissions, approvals and billing.

An AI Agent on Directual Without a Home-Made Loop: Plugging In theloop

In our piece on 10 myths about AI agents we said that an LLM is an executor, and to make it reliable you have to embed it in a system. In "AI Agents: Not a Trend — a Necessity" we listed the infrastructure that takes. Today we show how to do it in practice: the agent reads your data, proposes actions and waits for the user to approve them. And we won't be building the agent loop itself in Directual.

This isn't a thought experiment: the setup below already runs in production for one of our clients.

What an agent loop is and why to hand it off

A chatbot sends the model a question and shows the answer — that's what a basic ChatGPT integration with Directual looks like. An agent is more involved: the model can ask to call a tool ("find order 12345"), get the result, ask for another one, and only then answer. That's the loop: model → tools → model → … → final answer.

You can technically build this loop in a Directual scenario: an HTTP request to the provider, a condition "are there tool_calls", another HTTP request, a jump back. In a demo it even works. In production everything else shows up:

  • the provider returned a 429 or dropped the connection mid-answer;
  • the user wants to see the answer as it's generated, not after 40 seconds;
  • the model got stuck and is calling the same tool for the tenth time;
  • you need to count tokens for every attempt, failed ones included, to bill the client;
  • every provider has its own format for tool calls, caching and reasoning;
  • the user hit "stop".

That's work for an engine, not an orchestrator. We moved it into a separate open-source service, theloop.

theloop in a nutshell

theloop is the agent loop as a stateless HTTP service (Apache 2.0, Node 22). One POST /runs request is one run. The request carries everything: provider, model, key, system prompt, the full conversation history, tools (URL + JSON schema) and limits. The engine runs the loop to the final answer, streams text over SSE, and sends the result plus exact token usage to a callback. Between calls it remembers nothing.

That's why it fits Directual so well. Everything that needs storage stays with you: history, assistants, prompts, keys, operations, approvals, money. In theloop terms Directual is the harness, the orchestrator. The split rule is simple:

Anything solved by storing data or calling an endpoint goes to Directual. Anything that needs a loop, a stream or a timer goes to theloop.

Providers out of the box: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi and any OpenAI-compatible endpoint. You can pass a fallback chain: if the primary model fails before it starts answering, the engine moves on to the next one.

The setup

 Frontend ──writes──► raw_chat_message
    ▲                     │
    │ socket:             ▼
    │ stream_url    ┌──────────────── Directual ────────────────┐
    │               │ S1 "Start run"                            │
    │               │   collect history, prompt, tools          │
    │               │   HTTP request: POST /runs ───────────────┼──► theloop
    │               │                                           │       │
    │               │ Tool API endpoints ◄──────────────────────┼───────┤ tool calls
    │               │   read: return data                       │       │
    │               │   write: register an operation            │       │
    │               │                                           │       │
    │               │ Callback API endpoint ◄───────────────────┼───────┘ result
    │               │   store messages, usage, status           │
    │               └───────────────────────────────────────────┘
    │
    └──── SSE via stream_url (text as it's generated) ──── theloop

Directual stays out of the loop: it starts a run, answers tool calls like any other API, and accepts the result.

Step 0. Run theloop

You need one always-on container with a public HTTPS address:

docker run -p 8080:8080 \
  -e ENGINE_TOKEN=long-random-token \
  -e CALLBACK_SECRET=another-random-secret \
  -e PUBLIC_URL=https://loop.example.com \
  -e EGRESS_ALLOWLIST=api.directual.com \
  -e ALLOWED_ORIGINS=https://app.example.com \
  -e ENGINE_LOCALE=en \
  -e DEFAULT_TIMEZONE=Europe/London \
  ghcr.io/paveler/theloop:1

What matters here:

  • ENGINE_TOKEN is how Directual authenticates to the engine. Keep it in your app secrets and never send it to the frontend: the browser talks to the engine with one-time run tokens.
  • EGRESS_ALLOWLIST: the engine only calls allowed hosts, HTTPS only, no redirects, no private IPs. Tools and the callback live in the Directual API, so we allow api.directual.com.
  • CALLBACK_SECRET: the engine puts it in the X-Engine-Secret header of every callback. That's how Directual tells a real result from a forged one.
  • ENGINE_LOCALE and DEFAULT_TIMEZONE: by default the engine writes service strings in English and keeps time in UTC. If your users are in another language or time zone, set both — otherwise the model sees the date in the wrong zone and users get error messages in the wrong language.
  • Deploying the engine means a restart, and runs live in memory. Roll out updates during quiet hours: GET /health shows how many runs are in flight right now.

Provider keys can be set in env (ANTHROPIC_API_KEY=…) or passed from Directual with every request. The latter is handy when your clients bring their own keys.

Step 1. Structures

Reference data, which configures the assistant:

Structure Key fields
assistant provider, model, api_key (secret), system_prompt, params, limits, pricing, tools (links)
tool id (name the model sees), description, input_schema (json), endpoint, headers (secret), effect (read / write), enabled

This is probably the biggest win of the pairing. Model, prompt and tool set are data, not code. Want to compare Claude and GigaChat on the same conversation? Change two fields in the assistant record, no redeploy. A new tool is a new tool record plus an endpoint.

Working structures, which live inside the conversation:

Structure Key fields Purpose
raw_chat_message session, text, response fields stream_url, cancel_url, error the frontend writes here; it's the user's intent, not history
chat_session state (idle / running / awaiting_tool), active_run, history_revision the conversation and its state
chat_message id, session, payload (json), role, seq, created_at history in the engine's format
run id = run_id, status, deadline_at, result (json), usage fields, cost run results and billing

The key field is chat_message.payload. It holds the message in theloop's format (StoredMessage) whole and unchanged: user turns, model replies, tool calls and their results, reasoning. On every run these payloads go to the engine, in order, as messages. Don't try to rebuild history from what's visible in the UI: without tool results and reasoning the model loses context on the next step, and some providers will refuse to continue at all.

The other chat_message fields (role, a text preview, call_id) are denormalization for the UI and queries.

Step 2. The "Start run" scenario

Trigger: a new object in raw_chat_message.

1. Check the session state. If state = running, the user is typing while the agent is still answering. Do what a person does when interrupted: POST /runs/{id}/cancel. Cancellation is synchronous; the engine immediately returns the result with whatever text was already generated. Store it the same way as a callback result (step 5), send POST /runs/{id}/ack and move on. Never run two runs on one session at the same time: the second would get a history without the first one's answer.

2. Store the user message in chat_message. Use the raw_chat_message id as its id:

{ "role": "user",
  "content": [ { "type": "text", "text": "{{text}}" } ],
  "created_at": "{{date_created}}" }

Increment history_revision by 1. created_at is required: the engine uses it to show the model each message's date, the same one on every turn. That way history between turns is append-only and the provider's prompt cache keeps working.

3. Build the request body. A JS step is the easiest way (if you haven't worked with JSON in scenarios yet, start with the lesson on JSONs and links). It assembles system, the messages array (all session payloads sorted by created_at, seq) and the tools array from the reference records:

{
  "contract_version": "1",
  "idempotency_key": "{{raw_chat_message.id}}",
  "history_revision": {{chat_session.history_revision}},
  "llm": {
    "provider": "{{assistant.provider}}",
    "model": "{{assistant.model}}",
    "api_key": {{assistant.api_key | null}},
    "params": {{assistant.params}},
    "pricing": {{assistant.pricing | null}}
  },
  "system": "{{system}}",
  "messages": {{messages}},
  "tools": {{tools}},
  "limits": {{assistant.limits}},
  "callback": { "url": "<callback endpoint URL, step 5>" },
  "meta": {
    "session_id": "{{chat_session.id}}",
    "user_id": "{{user.id}}",
    "tenant_id": "{{tenant.id}}",
    "assistant_id": "{{assistant.id}}",
    "timezone": "Europe/London"
  }
}

A couple of practical details. Send empty fields as null, not an empty string: "api_key": "" fails validation. Build arrays in the JS step with a push loop, not concat. No need to add the date and time to the prompt: the engine appends them itself based on meta.timezone.

The system prompt can be assembled from data too. For example, a prompt core plus sections that depend on the conversation state: does the user have a filled-in profile, was the chat opened from a specific object's card. The same condition trims the tool set. The model has no say in this; plain scenario logic decides.

4. HTTP request: POST {ENGINE_URL}/runs with the header Authorization: Bearer {ENGINE_TOKEN}.

5. Handle the response:

  • 202: the run has started. Create a run with id = run_id, status = running and deadline_at = now + timeout_ms + 120 s. Set the session to state = running and write stream_url and cancel_url into raw_chat_message. The frontend picks them up over the socket and subscribes to the stream;
  • 200: a repeat of the same idempotency_key — the run is already going, no second one was started. Do the same as above;
  • 409 result_pending: the previous run's result hasn't been stored yet. Fetch it via GET /runs/{id} (step 7), store it, send ack and retry the request;
  • 429 or 503: the engine is overloaded or restarting. Wait a couple of seconds and retry;
  • 400: an error in the body you built; the response says exactly what.

The scenario ends here. No need to wait for the run to finish: the result will arrive at the callback.

Step 3. Read tools are your API endpoints

A tool in theloop is a URL plus a JSON schema for its arguments. When the model asks for a tool, the engine sends a POST to that URL. In Directual it's a regular API endpoint with a synchronous scenario, and the URL and auth header live in the tool record.

The body the endpoint receives:

{ "contract_version": "1", "run_id": "run_…", "call_id": "call_…", "attempt": 1,
  "origin_request_id": "…", "tool": "getOrders",
  "args": { "orderId": "12345" },
  "meta": { "session_id": "…", "user_id": "…", "tenant_id": "…" },
  "result_url": "…" }

The scenario runs the query and replies. The response format is set in the endpoint's API response:

  • { "status": "ok", "result": { … } }: the result goes to the model;
  • { "status": "error", "error": "order not found" }: the model sees the error and rephrases its request; the run doesn't fail.

The main security rule: filter data by meta, not by args. The model can put anything into args, including someone else's user_id if it was talked into it in the prompt. The model never sees meta and can't forge it: Directual sets it when it starts the run. So the scenario looks up orders with user = meta.user_id and uses orderId from args only as a filter inside that selection.

Three more tips:

  • Auth headers: only Authorization or X-*. For security reasons the engine passes only these to tools. A header with any other name (Api-Key, Token) is silently dropped, and the endpoint answers 401.
  • Return a compact result. The five order fields you need, not the whole object with its links. The engine truncates responses at tool_result_max_bytes, and you pay for every extra byte in tokens on every step of the loop.
  • An empty result is fine. No orders — return an empty list. The engine tells the model that there's no data and calling again won't change that, so there's no looping.

Step 4. Write tools: the agent proposes, a human approves

The interesting part is tools that change something: issue a refund, cancel an order, charge money. In theloop they have "effect": "write", and they follow a special rule:

A write endpoint changes nothing. It registers the intent — creates an operation — and always replies { "status": "deferred", "operation_id": "…" }.

In Directual it's the same API endpoint with a synchronous scenario, and the format is set in API response: { "status": "deferred", "operation_id": "{{operation.id}}" }.

On deferred, the engine ends the run with status awaiting_tool and forgets about it. Everything after that happens in Directual:

  1. The createReturn endpoint scenario creates an object in operation with status pending and replies deferred.
  2. The frontend sees the new operation over the socket and shows a card: "Refund $49.90 for order 12345? [Approve] [Decline]".
  3. On click, the scenario performs the action or declines it, writes the tool result into chat_message (id = call_id) and starts a new run, this time without a new user message:
{ "role": "tool", "call_id": "call_…", "tool": "createReturn",
  "operation_id": "op_…", "result": { "return_id": "R-881" }, "is_error": false }

On decline, write "is_error": true, "error": "declined by user", and the model will explain to the user what happens next.

Why this way and not "let the tool issue the refund right away"? Three reasons:

  • Prompt injection. If the model was tricked by a customer email or text on a website, it can ask for an action but can't perform it. Execution always goes through your logic and a human's approval.
  • Retries. The engine retries tool calls on network errors, and after a failure a run may be restarted from scratch. If the tool charged money directly, you'd get a double charge. Registering an intent is safe to repeat.
  • Long operations. deferred fits anything that takes hours: a manager's sign-off, a bank's response, document generation. The engine doesn't hold a connection, and Directual can wait as long as needed.

To keep a repeated call from creating a second operation, its id must be deterministic: op_ + sha256 of meta.session_id, the tool name and origin_request_id (the user message id). sha256 is computed right in the scenario. A repeated call with the same data lands on the same object. Check the operation's version before executing it: the model may have changed the amount in a later call, and the user must not approve something they didn't see. When executing, pass operation.id to the external system (bank, payment provider) as its idempotency key. The full algorithm, including finding an existing operation, is in the spec, §6.4.

If the user types something new instead of clicking the card, close the pending call with a synthetic result "error": "superseded" and start a run with the new message. The operation itself stays, and its card can still be approved later.

Step 5. Callback: receiving the result

When a run ends, the engine sends a POST to callback.url. The body comes in an envelope:

{ "payload": { "run_id": "run_…", "status": "completed", "messages": [ … ],
               "usage": { … }, "tool_calls": [ … ], "meta": { … }, … } }

The single-field envelope is there specifically for Directual endpoints. An endpoint accepts only fields from its writable list. With a flat body you'd get … is not allowed for write on the first unknown field, and a messages array in a json field would fail with unsupported conversion from array to json. With the envelope the endpoint needs one writable field — payload (json) — and new result fields won't break it.

The endpoint's synchronous scenario:

  1. Checks the signature. Compares the X-Engine-Secret header to a constant and rejects the request on mismatch. Don't put the secret in the callback URL's query string: query strings end up in proxy logs.
  2. Upserts run by payload.run_id. The callback is delivered at-least-once; there will be duplicates. The endpoint itself doesn't set the record id, so the scenario handles deduplication: it looks up run by run_id and updates it instead of creating a new one. It writes result, status, usage fields and cost into run.
  3. Writes the messages. For each item in payload.messages, upsert a chat_message with id = <run_id>:<seq> and payload as is. These are only the new messages from this run; the engine never returns old ones. The deterministic id makes redelivery safe: a duplicate overwrites the same objects.
  4. Updates the session. completed, stopped_limit, cancelled and failed move it to idle; awaiting_tool moves it to awaiting_tool with the list of pending call_ids. history_revision is incremented by 1.
  5. Replies 200 only after all writes. To the engine, 2xx means "result stored": after that it releases the session lock. If the scenario failed halfway, reply with an error and the engine will redeliver (5 attempts over about 80 seconds). All writes are idempotent, so a retry duplicates nothing.

Before wiring up the engine, test the endpoint by hand: send a real result body with curl twice. After the second time there must be no duplicates in chat_message.

About stopped_limit: if the model hits max_steps, the engine gives it one last step without tools and still returns a text answer. It's not an error; show it as a normal reply. On failed, error.message already contains a user-friendly text in the ENGINE_LOCALE language. Show it as a system message; don't write it into history.

Step 6. Streaming and stop

The frontend sees stream_url in its raw_chat_message over the socket and subscribes to the stream directly on the engine. No proxy through Directual is needed: the link already contains a token valid for this run only.

const es = new EventSource(msg.stream_url)
es.addEventListener('delta', e => appendText(JSON.parse(e.data).text))
es.addEventListener('tool_call', () => showStatus('Looking at the data…'))
es.addEventListener('done', () => es.close())

By default the stream carries only text and anonymized progress: no tool arguments, no tool results. Results may contain data the model needs to see but the user shouldn't see in full.

The "Stop" button is a POST cancel_url straight from the browser. Directual doesn't need to do anything special: the cancelled run's result arrives as a regular callback.

A subtle point: a done event in the browser doesn't mean history has been stored. Stored messages appear in chat_message after the callback; the stream is only a preview.

Step 7. A safety net for lost runs

theloop keeps runs in memory. If the container crashes, the run is gone and no callback arrives. So you need a scheduled scenario, once a minute:

  • find run with status = running and deadline_at < now;
  • call GET /runs/{id}. If a finished result comes back, store it as in step 5 and send POST /runs/{id}/ack. If the run is still going, extend the deadline;
  • on 404 the run is lost: run.status = lost, session to idle, and a message to the user: "couldn't get an answer, please try again".

lost isn't final. If a late callback does arrive, accept it: compare the history_revision in the result to the current one. If the revisions match, store it as usual. If the next turn has already happened, keep the result for audit but don't insert it into history.

What you end up with

Directual theloop
UI, users, permissions the agent loop
conversation history and its order streaming and cancellation
assistants, prompts, models and keys as data provider retries, fallbacks, rate limiting
data and tools as API endpoints tool calls, argument validation, loop protection
operations, approvals, executing actions deferred instead of mutations
client billing from usage exact token accounting per attempt

Each side does its own job. Directual doesn't need to learn provider retries and SSE, and the engine doesn't need to know who your clients are and what they're allowed to do. Multi-tenancy comes for free: the engine holds no tenant state; everything arrives in the request.

What theloop isn't, at least for now: it's not an agent builder, not RAG and not a multi-agent system. One instance, runs in memory. Need knowledge-base search? Make it a read tool: an endpoint in Directual that searches and returns snippets.

Where to start

  1. Run theloop and send the request from the README with "wait": true. The response then comes right in the request body — exactly the same body that will later arrive at the callback.
  2. Create the structures from step 1 in Directual, one assistant and one read tool. If API endpoints and HTTP requests in scenarios are new to you, check out the API tutorial for beginners.
  3. Build the run start and the callback, and test the callback by sending it twice. Then add streaming, the deadline safety net and write tools.

The full contract is in SPEC.md. Appendix A is especially useful: a checklist of the orchestrator's obligations. Most production issues come from skipping one of those items. An example orchestrator on SQLite lives in examples/sqlite-harness: same logic, just tables instead of Directual structures.

Send questions, bugs and stories about the agent you built on Directual to GitHub issues.

If you're just getting started with agents, take our free AI agents course: in about an hour it gives you the foundation this setup builds on. More on what Directual offers for agent products is on the AI agents on Directual page.

FAQ

What is theloop?

theloop is an open-source (Apache 2.0) agent loop packaged as a stateless HTTP service. One POST /runs request is one run: the engine calls the model and tools until the final answer, streams text over SSE and sends the result with exact token usage to a callback. It keeps nothing between calls.

Why move the agent loop out of Directual if you can build it in a scenario?

A loop of HTTP requests and conditions works in a demo, but production needs retries on 429s and dropped connections, streaming, loop protection, token accounting per attempt, different tool-call formats per provider and a stop button. That is engine work, not orchestrator work. Directual keeps history, data, permissions, approvals and billing.

How does the agent safely perform actions like refunds or charges?

Write tools change nothing by themselves: the Directual endpoint registers an operation and replies with status: deferred. The user sees a confirmation card, and only after they click does the scenario perform the action. Prompt injection cannot trigger a real action, and retries cannot cause double charges.

How do I stop the model from reading other users' data through tools?

Filter by meta, not by args. The model writes args and can be talked into passing someone else's user_id. Directual sets meta when it starts the run; the model never sees it and cannot forge it.

Which LLM providers does theloop support?

Out of the box: Anthropic, OpenAI, DeepSeek, GLM, GigaChat, MiniMax, Kimi and any OpenAI-compatible endpoint. You can pass a fallback chain: if the primary model fails before it starts answering, the engine moves on to the next one.
← Back to list