Skip to content

Search docs

Jump between documentation pages.

Browse docs

Tutorial: host an agent loop on DaloyJS

DaloyJS does not run an LLM or an agent loop in core, on purpose. The loop is where your prompts, your model provider, and your memory live, and that belongs to you or to an agent framework. What DaloyJS is good at is everything around the loop that is easy to get wrong: streaming, auth, rate limits, validation, signed state, and audit trails.

We will host a small agent session API: the model calls tools, safe tools run straight away, risky tools pause for a human, and every step streams to the client. The complete, tested code is examples/agent-loop.ts in the DaloyJS repository. Its test suite runs this exact code, including the attacks.

A risky tool call with human approval
ClientDaloyJSYour model
  1. 01requestClientDaloyJSPOST /sessions/:id/messages"refund order A-1"
  2. 02requestDaloyJSYour modelmodel(history, signal)wants refundOrder
  3. 03responseDaloyJSClientevent: approval_requiredsigned, single-use approvalToken
  4. 04noteClientClientA human reviews the action
  5. 05requestClientDaloyJSPOST /sessions/:id/approvals{ approvalToken, approve: true }
  6. 06noteDaloyJSDaloyJSVerify: this user, session, tool callthen clear it (no replay)
  7. 07responseDaloyJSClientevent: tool_result, assistant, done
Nothing risky runs until the exact approval token comes back. A token for another session, another user, or an old call is refused, and a replay finds nothing pending.

1. The model and the tools

The model is a plain function, so you are not locked to a provider. Call your LLM SDK inside it and map the reply to a ModelTurn. Tools declare a Zod input schema, and risky: true marks the ones a human must approve (refunds, deletes, sending email, anything with side effects you would regret).

ts
// Your LLM call. Any provider fits: call its SDK here and map the reply.
export type ModelTurn =
  | { type: "text"; text: string }
  | { type: "tool"; name: string; input: unknown };

export type Model = (history: readonly HistoryItem[], signal: AbortSignal) => Promise<ModelTurn>;

// A tool the model may call. Risky tools need a human approval first.
export interface AgentTool<I = unknown> {
  description: string;
  input: z.ZodType<I>;
  risky?: boolean;
  run(input: I, ctx: { user: string; signal: AbortSignal }): unknown | Promise<unknown>;
}

2. The loop

The loop is an async generator: each step it yields becomes a server-sent event. It stops on a text answer, on a pause for approval, when the request is aborted, or when the step budget runs out. A tool the model invented, or input that fails the schema, never runs: the error goes back into the history so the model can try again.

ts
async function* loop(session: Session, signal: AbortSignal): AsyncGenerator<SSEMessage> {
  for (let step = 0; step < maxSteps; step++) {
    if (signal.aborted) return;
    const turn = await options.model(session.history, signal);
    if (turn.type === "text") {
      session.history.push({ role: "assistant", content: turn.text });
      yield { event: "assistant", data: { text: turn.text } };
      yield { event: "done", data: {} };
      return;
    }
    const tool = options.tools[turn.name];
    const parsed = tool?.input.safeParse(turn.input);
    if (!tool || !parsed?.success) {
      // Unknown tool or bad input: tell the model and let it try again.
      const error = !tool ? `Unknown tool "${turn.name}".` : "Invalid tool input.";
      session.history.push({ role: "tool", tool: turn.name, content: error });
      yield { event: "tool_error", data: { tool: turn.name, error } };
      continue;
    }
    const toolCallId = crypto.randomUUID();
    if (tool.risky) {
      // ...sign an approval token and pause (next section).
    }
    yield* execute(session, turn.name, parsed.data, toolCallId, signal);
  }
  yield { event: "budget_exhausted", data: { maxSteps } };
}

3. Stream it

sseResponse turns the generator into a streaming response, and passing request.signal stops the loop when the client disconnects or the request times out, so you do not keep paying for model calls nobody is reading. Sessions are looked up by owner, so another user gets 404, not someone else's agent.

ts
app.post(
  "/sessions/:id/messages",
  {
    operationId: "sendSessionMessage",
    request: {
      params: z.object({ id: z.string().min(1) }).strict(),
      body: z.object({ text: z.string().min(1).max(8_000) }).strict(),
    },
    acknowledgeNoResponseBodySchema: true,
    responses: { 200: { description: "Server-sent events for each loop step" } },
  },
  ({ params, body, state, request }) => {
    const session = sessionFor(userOf(state), params.id); // 404 if not yours
    if (session.pending) {
      return Response.json({ error: "Waiting for an approval on this session." }, { status: 409 });
    }
    session.history.push({ role: "user", content: body.text });
    return sseResponse(() => loop(session, request.signal), { signal: request.signal });
  }
);

4. Pause for a human

When the model picks a risky tool, the loop stores the pending call and returns an approval token instead of running it. The token is a short-lived signed JWT (createJwtSigner) bound to this user, this session, and this exact tool call. The server keeps the pending input, so the client cannot swap in different arguments while approving.

ts
if (tool.risky) {
  session.pending = { toolCallId, name: turn.name, input: parsed.data };
  const now = Math.floor(Date.now() / 1000);
  const approvalToken = await signer.sign({
    sub: session.owner, // this user
    sid: session.id,    // this session
    tcid: toolCallId,   // this exact tool call
    iat: now,
    exp: now + ttl,     // short-lived
  });
  audit({ type: "approval_requested", user: session.owner, sessionId: session.id, tool: turn.name, toolCallId });
  // Pause: nothing risky runs until a human sends this token back.
  yield { event: "approval_required", data: { toolCallId, tool: turn.name, input: parsed.data, approvalToken } };
  return;
}

Resuming verifies the signature and expiry, checks every binding, and clears the pending call before running it, so the same token cannot approve twice:

ts
let claims: Record<string, unknown>;
try {
  claims = (await verifier.verify(body.approvalToken)).payload;
} catch {
  return Response.json({ error: "Invalid or expired approval token." }, { status: 403 });
}
// Bound to this user, this session, and this exact tool call.
if (claims.sub !== owner || claims.sid !== session.id || claims.tcid !== pending.toolCallId) {
  return Response.json({ error: "This approval token does not match." }, { status: 403 });
}
// Single use: clear it before running, so a replay finds nothing pending.
session.pending = undefined;

5. Guardrails around the loop

Put real authentication in front (for example jwk()), and give each user a rate limit on how often they can drive the loop. The step budget (maxSteps) caps how many model calls one message can cost. Every tool call and approval decision goes to onAudit, which is where you connect your audit store.

ts
app.use(markAuthHook({ /* your real auth, e.g. jwk() */ }));
// Per-user budget on how often a client may drive the loop.
app.use(
  rateLimit({
    windowMs: 60_000,
    max: 30,
    keyGenerator: (ctx) => `user:${String((ctx.state as Record<string, unknown>).user)}`,
  })
);

Two more rules the example follows: a failing tool streams a generic "The tool failed." rather than its error text, and a new message on a session with a pending approval gets 409 until the human decides.

6. What the client sees

http
POST /sessions/3f0e.../messages
{ "text": "refund order A-1" }

event: tool_result
data: {"toolCallId":"...","tool":"lookupOrder","output":{"orderId":"A-1","total":42}}

event: approval_required
data: {"toolCallId":"9b1c...","tool":"refundOrder","input":{"orderId":"A-1"},"approvalToken":"eyJ..."}

POST /sessions/3f0e.../approvals
{ "approvalToken": "eyJ...", "approve": true }

event: tool_result
data: {"toolCallId":"9b1c...","tool":"refundOrder","output":{"orderId":"A-1","refunded":true}}

event: assistant
data: {"text":"Refunded order A-1."}

event: done
data: {}

7. Test the attacks, not only the happy path

The example's tests prove each guard with a real attempt:

  • An approval token for another session is refused.
  • A tampered token and a token signed with another secret are refused.
  • Another user cannot see the session at all.
  • Replaying a used token finds nothing pending.
  • A model that calls tools forever hits the step budget.
  • Unknown tools and bad input never run, and tool errors never leak.

Next steps

  • Let the loop delegate to other agents with createA2aClient(), and forward trace context so one request stays one trace.
  • Move long-running tool work to background jobs and stream progress, instead of holding one request open.
  • Store sessions in your database: the in-memory map in the example is fine for one instance only.
  • Expose the same tools to AI clients with the MCP server.