---
title: "Agents"
description: "Bounded agents — tools are Flows, maxSteps and budget.maxCostPerRun cap the loop, invoke with fx.run."
icon: "Bot"
source: "docs/spec/unified-theory.md"
---

An agent (`ai.agent`) is a **bounded tool-calling loop**. Tools are your app’s Flows (and
allowlisted MCP refs) — each step goes through `fx.call`. The loop stops at `maxSteps` or
`budget.maxCostPerRun`.

For developers who need a planner or support assistant without writing a custom tool runtime —
declare the bag, run with `fx.run`.

<Callout title="The one rule">
  Pass Flow names (or handles) in `tools`, set `maxSteps` and `budget.maxCostPerRun`, then
  `fx.run(agent, {message})`. There is no `instructions` / `in` / `out` on `ai.agent` — shape the
  user message yourself.
</Callout>

## Smallest Example

<Steps>

<Step>
### Declare model + agent

```typescript title="src/core/ai.ts"
import { ai } from "okengine";

export const smart = ai.model("smart", {
  provider: "openrouter",
  model: "openrouter/free",
  apiKey: process.env.OPENROUTER_API_KEY,
});

export const supportAgent = ai.agent("support.assistant", {
  model: "smart",
  tools: ["docs.search", "tickets.create"],
  maxSteps: 5,
  budget: { maxCostPerRun: 0.1 },
});
```

</Step>

<Step>
### Run from a Flow

```typescript title="src/flows/support/assist.ts"
import { on, flow, http } from "okengine";
import { z } from "zod";
import { supportAgent } from "@/core/ai";
import { member } from "@/core/gate";

export const assist = on(
  http
    .post({
      in: z.object({ query: z.string().min(1) }),
    })
    .gate(member),
  flow({
    do: async ({ query }, fx) => {
      return await fx.run(supportAgent, {
        message: `Help the member: ${query}`,
      });
    },
  }),
);
```

Runtime records the agent name under `asks`. Prefer declaring `effects: { asks: ["support.assistant"] }`
when inference does not see `fx.run`.

</Step>

<Step>
### Bound termination

Default `maxSteps` is **6**. `stopReason` is `completed`, `max_steps` (`ok: false`),
`budget` (partial trail, no throw), or `error` (a thrown tool, rethrown). A denial fed
back to the model stays `completed`.

</Step>

</Steps>

## Progressive Patterns

<Tabs items={["Flow tools", "MCP tools", "Ask tools", "Budget"]}>

<Tab value="Flow tools">

String names or Flow handles — same capability path as `fx.call`:

```typescript
import { searchDocs } from "@/flows/docs/search";
import { createTicket } from "@/flows/tickets/create";

export const supportAgent = ai.agent("support.assistant", {
  model: smart,
  tools: [searchDocs, createTicket],
  maxSteps: 8,
});
```

</Tab>

<Tab value="MCP tools">

Outbound MCP refs join the same bag (`mcp:<server>/<tool>`):

```typescript
const github = ai.mcpServer("github", {
  url: "https://mcp.example/github",
  auth: { bearer: vault.secret("GITHUB_MCP_TOKEN") },
  tools: ["create_issue"],
});

export const planner = ai.agent("planner", {
  model: "smart",
  tools: ["tasks.list", github.tool("create_issue")],
  maxSteps: 8,
  budget: { maxCostPerRun: 0.25 },
});
```

See [MCP](/docs/elements/ai/mcp).

</Tab>

<Tab value="Ask tools">

One-shot tool loop on `fx.ask` without a named agent:

```typescript
await fx.ask(triage, input, {
  tools: ["docs.search"],
  maxSteps: 3,
});
```

</Tab>

<Tab value="Budget">

Run cost is on `budget`, not a top-level `maxCostPerRun` field:

```typescript
ai.agent("planner", {
  tools: ["tasks.list", "tasks.create"],
  budget: { maxCostPerRun: 0.25, maxCostPerCall: 0.05 },
});
```

</Tab>

</Tabs>

## Options

| Option     | Type                                                         | Default     | Meaning                      |
| ---------- | ------------------------------------------------------------ | ----------- | ---------------------------- |
| `model`    | `AiModelDecl` \| `string`                                    | first model | Logical binding for the loop |
| `tools`    | Flow ref, MCP ref, or `{ name, approval?, gate?, timeout? }` | `[]`        | Callable via `fx.call`       |
| `maxSteps` | `number`                                                     | `6`         | Hard cap on tool rounds      |
| `maxDepth` | `number`                                                     | `3`         | Nested agent limit           |
| `budget`   | `{ maxCostPerCall?, maxCostPerRun? }`                        | —           | Cost contracts for the run   |

## `fx.run` input

Pass a string, `{ message }`, or `{ messages }` (user, assistant, and tool turns). Passing both
`message` and `messages` throws. History stays in your Store — `fx.run` does not persist it.

```typescript
await fx.run(supportAgent, "Summarize open tickets");
await fx.run(supportAgent, { message: "Summarize open tickets" });
await fx.run(supportAgent, {
  messages: [
    { role: "user", content: "Summarize open tickets" },
    { role: "assistant", content: "There are 3." },
    { role: "user", content: "Open the oldest." },
  ],
});
```

## Agents as tools

Another agent can sit in `tools`. The child uses its own `maxSteps`. Its cost cap is the
smaller of its budget and what the parent has left. Past `maxDepth` (default 3) the tool
result is an error and no further child starts.

```typescript
const lookup = ai.agent("lookup", { tools: ["orders.get"], maxSteps: 3 });
const support = ai.agent("support", { tools: [lookup, "billing.refund"] });
```

## Tool approval

<Callout>
A tool with `approval` parks the run until someone calls `fx.agent.approve` or `fx.agent.deny`. The calling Flow must set `durable: true`. The wait defaults to `24h`, then the model sees `{ denied: true, reason: "timeout" }`.
</Callout>

```typescript
const support = ai.agent("support", {
  tools: [{ name: "billing.refund", approval: true, gate: ops, timeout: "1h" }],
});

on(
  http.post("/assist"),
  flow("assist", {
    durable: true,
    do: async (_input, fx) => fx.run(support, { message: "Refund order 14" }),
  }),
);
```

`gate` is who may resolve. A function for `approval` waits only when it returns true.
Approve may replace `args`. A second resolution is HTTP 409. Deny is not `stopReason: "denied"` —
the model continues with `{ denied: true, reason }`.

## Agents vs ask-with-tools

| Surface                        | When                                         |
| ------------------------------ | -------------------------------------------- |
| `ai.agent` + `fx.run`          | Reusable tool bag, shared step/budget policy |
| `fx.ask(prompt, …, { tools })` | One prompt, occasional tools                 |

Both default `maxSteps` to `6`. Denied or unknown tools surface as
`ai: all tool calls denied for prompt "…"` / `ai: model requested unknown tool "…"`.

## Troubleshooting

<Accordions>

<Accordion title='ai: unknown agent "…"'>
  `fx.run` named an agent that was never declared, or the declaring module was not imported before
  `oke()`.
</Accordion>

<Accordion title="The agent stopped before it finished">
  `fx.run` returns the partial trail when `budget.maxCostPerRun` is spent. It does not throw. Raise
  the budget, lower `maxSteps`, or shrink the tool bag.
</Accordion>

<Accordion title='ai: model requested unknown tool "…"'>
  The model emitted a tool name outside the declared `tools` bag. Align names with Flow / MCP refs,
  or tighten instructions in the user `message`.
</Accordion>

<Accordion title="must set durable: true">
  An approval tool cannot wait in memory. Set `durable: true` on the Flow that calls `fx.run`.
</Accordion>

<Accordion title="OKE1005 on agent runs">
  Runtime gates `fx.run` as an ask of the agent name. List `effects: { asks: ["support.assistant"] }`
  when the compiler does not infer `fx.run`.
</Accordion>

</Accordions>

## Learn more

- [Prompts](/docs/elements/ai/prompts) — `fx.ask` and ask-time tools
- [MCP](/docs/elements/ai/mcp) — inbound tools and outbound servers
- [Flow](/docs/elements/flow) — tools are ordinary Flows
- [AI](/docs/elements/ai) — guardrails figure and drivers

## Next

<Cards>
  <Card
    title="MCP"
    description="Expose and consume Model Context Protocol tools."
    href="/docs/elements/ai/mcp"
  />
  <Card
    title="Prompts"
    description="Versioned fx.ask artifacts."
    href="/docs/elements/ai/prompts"
  />
  <Card title="AI" description="Element overview." href="/docs/elements/ai" />
</Cards>
