> ## Documentation Index
> Fetch the complete documentation index at: https://app.keystroke.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Build agents

> Define agents and configure models, tools, memory, and more.

You build an agent by writing a `defineAgent()` definition in your project. This page starts from the smallest working agent, then serves as the full reference for every option you can configure.

## Build a simple agent

You can build a simple agent in just a few steps.

<Steps>
  <Step title="Define the agent">
    An agent needs only three things: a `slug`, a `systemPrompt`, and a `model`. Create a file in `src/agents/` that default-exports the definition.

    ```ts src/agents/support.ts theme={null}
    import { defineAgent } from "@keystrokehq/keystroke/agent";

    export default defineAgent({
      slug: "support",
      systemPrompt: "You are a helpful support assistant. Answer concisely.",
      model: "anthropic/claude-sonnet-4.6",
    });
    ```

    The `slug` is the stable key Keystroke uses for discovery, routes, CLI commands, and history.
  </Step>

  <Step title="Deploy it">
    Ship the project to the platform so the agent runs in the cloud.

    ```bash theme={null}
    keystroke deploy --project <project-slug>
    ```

    Deploy builds and uploads your project, and the CLI now targets it automatically. See [deploy a project](/learn/projects/deploy-a-project) for project setup.
  </Step>

  <Step title="Use it">
    Prompt the deployed agent by its slug, or open **Agents** in the web app.

    ```bash theme={null}
    keystroke agents prompt support --message "Help me understand my bill"
    ```

    The response includes a `sessionId`, the messages, and any error. For repeatable checks, add a [test](/learn/agents/test-agents) that prompts the agent or asserts its definition. You can also use the agent directly from Slack (see [external channels](/learn/agents/external-channels)). Prefer to iterate without deploying? Run a local server with `keystroke dev`.
  </Step>
</Steps>

That is a complete, working agent. The rest of this page is the full reference for configuring one.

## Configuration reference

Every agent ships with some capabilities out of the box, then accepts options to change those defaults or add more.

These capabilities are built in, locally and once deployed, with no configuration:

| Built in               | What you get                                                                         | Tune it with                              |
| ---------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------- |
| **Workspace**          | Each session gets an isolated `/workspace` with file and bash (code execution) tools | [`sandbox`, `mode`](#sandboxes)           |
| **Web access**         | `web_search` and `web_fetch` tools when a web provider is configured                 | [Web search](#web-search)                 |
| **Memory**             | Session history plus persistent memory, enabled by default                           | [`memory`](#memory)                       |
| **Ephemeral triggers** | `set_trigger` and `list_triggers` tools so the agent can schedule its own runs       | [Ephemeral triggers](#ephemeral-triggers) |

`defineAgent()` accepts these options. The required three are all you need to start; the rest are optional.

| Option          | Required | What it does                                                                                                          |
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `slug`          | Yes      | Stable key used for discovery, routes, CLI commands, and history                                                      |
| `systemPrompt`  | Yes      | Instructions given to the model                                                                                       |
| `model`         | Yes      | Exact model id from the [catalog](https://keystroke.ai/models.md) in `vendor/model-id` format. See [Models](#models). |
| `name`          | No       | Human-readable name shown in the platform                                                                             |
| `description`   | No       | Short description shown in the platform                                                                               |
| `thinkingLevel` | No       | Reasoning setting. See [Models](#models).                                                                             |
| `tools`         | No       | Actions, subagents, workflows, or MCP tools. See [Tools](#tools).                                                     |
| `skills`        | No       | Project skill folders. See [Skills and files](#skills-and-files).                                                     |
| `sandbox`       | No       | Workspace file layout and execution mode (`defineSandbox({ files, mode })`). See [Sandboxes](#sandboxes).             |
| `memory`        | No       | Memory options, or `false` to disable. See [Memory](#memory).                                                         |

<Note>
  Agent definitions do not have a `credentials` option. Credentials are declared on the actions an agent uses, then resolved when those tools run.
</Note>

## Tools

Tools allow agents to do real work: look up a customer, send an email, run a workflow, or call another agent. You list the tools an agent is allowed to use in `tools`.

Tools come in a few forms:

| Tool type    | What it is                                                     | Add it with                 |
| ------------ | -------------------------------------------------------------- | --------------------------- |
| **Action**   | One of your functions, or an action exported by an integration | `tools: [myAction]`         |
| **Subagent** | Another agent, exposed as a callable tool                      | `tools: [researcher]`       |
| **Workflow** | A durable, multi-step workflow run as a single tool            | `tools: [refundOrder]`      |
| **MCP tool** | Tools served by an external MCP server                         | [`defineMcp()`](#mcp-tools) |

### Actions as tools

The most common tool is an [action](/learn/actions/overview). The same action works as an agent tool or a workflow step; you just add it to `tools`.

```ts theme={null}
import { defineAgent } from "@keystrokehq/keystroke/agent";
import { lookupCustomer } from "../actions/lookup-customer";

export default defineAgent({
  slug: "support",
  systemPrompt: "Help customers. Use lookupCustomer to find account details.",
  model: "anthropic/claude-sonnet-4.6",
  tools: [lookupCustomer],
});
```

Integration packages also export actions. Import only the ones the agent should be allowed to use.

```ts theme={null}
import { defineAgent } from "@keystrokehq/keystroke/agent";
import {
  googlesuperFetchEmails,
  googlesuperSendEmail,
} from "@keystrokehq/googlesuper/actions";

export default defineAgent({
  slug: "secretary",
  systemPrompt: "Help manage Gmail. Confirm ambiguous sends before acting.",
  model: "openai/gpt-5.5",
  tools: [googlesuperFetchEmails, googlesuperSendEmail],
});
```

Reach for [actions](/learn/actions/agent-tools) for deterministic single-step capabilities, [workflows](#workflows-as-tools) for durable multi-step sequences, [subagents](#subagents-as-tools) for open-ended delegation, and [MCP tools](#mcp-tools) when an external server exposes an MCP surface.

### Subagents as tools

A subagent is an agent exposed as a tool to another agent. Use one when a parent agent should delegate a specialized task (research, a stronger model, a different tool set) without sharing the whole parent conversation as instructions.

Import the agent and add it to the parent agent's `tools`. The tool name is the subagent's `slug`, and the tool expects a `message` string parameter.

```ts theme={null}
import { defineAgent } from "@keystrokehq/keystroke/agent";
import researcher from "./researcher";

export default defineAgent({
  slug: "orchestrator",
  systemPrompt:
    "You are an orchestrator. For research tasks, call researcher before answering.",
  model: "anthropic/claude-sonnet-4.6",
  tools: [researcher],
});
```

Subagent calls appear as tool calls in the parent session, and the child agent creates its own session. Inspect both in [run history](/learn/logs/agent-runs).

### Workflows as tools

A [workflow](/learn/workflows/overview) packages a fixed, multi-step sequence, often several actions chained together with durable retries. Import it into `tools` when you want the agent to trigger that whole sequence as a single, reliable step instead of orchestrating the steps itself.

```ts theme={null}
import { defineAgent } from "@keystrokehq/keystroke/agent";
import refundOrder from "../workflows/refund-order";

export default defineAgent({
  slug: "support",
  systemPrompt:
    "Help customers. To issue a refund, call the refund-order tool with the order ID.",
  model: "anthropic/claude-sonnet-4.6",
  tools: [refundOrder],
});
```

The tool's name and parameters are derived from the workflow's `slug` and input schema, so you do not declare them by hand. The agent calls it like any other tool, and the workflow runs with its normal durability and step recording. Inspect the workflow run and the agent session together in [run history](/learn/logs/agent-runs).

Choose a workflow tool when the work is a known sequence that should run reliably, and a [subagent](#subagents-as-tools) when the work needs open-ended reasoning.

### MCP tools

[Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers expose tools over a standard protocol. Point an agent at one with `defineMcp()`, and every tool that server lists becomes available to the agent.

```ts theme={null}
import { defineAgent, defineMcp } from "@keystrokehq/keystroke/agent";

const deepwiki = defineMcp({
  key: "deepwiki",
  transport: { type: "http", url: "https://mcp.deepwiki.com/mcp" },
});

export default defineAgent({
  slug: "researcher",
  systemPrompt: "Use the DeepWiki tools to answer questions about GitHub repositories.",
  model: "anthropic/claude-sonnet-4.6",
  tools: [deepwiki],
});
```

Keystroke connects to the server when the agent runs and adds its tools to the agent for that session. Tool names are prefixed with the server `key` (for example `mcp__deepwiki__ask_question`) so they never collide with your other tools. For servers that require authentication, declare credentials on the definition. See [credentials](/learn/credentials/overview).

<Note>
  This is the client side of MCP: your agent using an external MCP server. For the reverse, building Keystroke agents, workflows, and triggers from an MCP-capable agent like ChatGPT or Claude, see [MCP for agents](/build-with-ai/mcp-for-agents).
</Note>

## Skills and files

Skills are reusable instructions in `src/skills/`. Files are static project context in `src/files/`. Both materialize into the agent workspace before a prompt runs, so the agent can read them like local files.

```ts theme={null}
import { defineSandbox } from "@keystrokehq/keystroke/sandbox";

export default defineAgent({
  slug: "support",
  systemPrompt:
    "Read /workspace/agent/product-guide.md and the support skill before answering.",
  model: "anthropic/claude-sonnet-4.6",
  skills: ["support"],
  sandbox: defineSandbox({ files: true }),
});
```

With `defineSandbox({ files: true })`, Keystroke uses `src/files/{agentSlug}/`. You can also pass a string to use another file set:

```ts theme={null}
export default defineAgent({
  slug: "support",
  systemPrompt: "Read the support handbook before answering.",
  model: "anthropic/claude-sonnet-4.6",
  sandbox: defineSandbox({ files: "support-handbook" }),
});
```

See [project skills](/learn/skills/overview) and [files](/learn/files/overview) for the file formats.

## Memory

Memory is what lets an agent remember. It is enabled by default and has two parts:

| Part                  | What it does                                                                                                                                                          |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Session history**   | Keeps the messages for one conversation so follow-up prompts have context                                                                                             |
| **Persistent memory** | Gives the agent a `memory` tool and a filesystem-backed memory area (`MEMORY.md`, `USER.md`, archive notes, and searchable past sessions) stored outside `/workspace` |

Set `memory: false` for a stateless agent, such as a deterministic classifier or a one-shot extraction agent:

```ts theme={null}
export default defineAgent({
  slug: "one-shot-classifier",
  systemPrompt: "Classify the message and return only the label.",
  model: "anthropic/claude-sonnet-4.6",
  memory: false,
});
```

Persistent memory is **agent-curated**: the agent writes and edits `USER.md`, `MEMORY.md`, and archive notes itself through the `memory` tool. You don't pre-load it from the definition — put stable, author-provided context in the [`systemPrompt`](#models) or in [files](#skills-and-files) instead. The agent then records what it learns into memory over time.

You can pass an options object to tune memory's limits:

```ts theme={null}
export default defineAgent({
  slug: "support",
  systemPrompt: "Help the user using memory when it is relevant.",
  model: "anthropic/claude-sonnet-4.6",
  memory: {
    memoryCharLimit: 3000,
  },
});
```

The `memory` object accepts these options:

| Option            | Default | What it does                                                               |
| ----------------- | ------- | -------------------------------------------------------------------------- |
| `memoryCharLimit` | `2200`  | Char budget for `MEMORY.md`; the memory tool rejects writes that exceed it |
| `userCharLimit`   | `1375`  | Char budget for `USER.md`; the memory tool rejects writes that exceed it   |
| `archiveTocLimit` | `30`    | Max archive notes listed in the memory snapshot                            |

The char limits keep `MEMORY.md` and `USER.md` concise (overflow belongs in unbounded archive notes), so the agent gets a clear error and trims when a write would exceed the budget.

Continue a conversation by passing the same `sessionId` to the next prompt. See [run agents](/learn/agents/run-agents) for session commands.

## Models

The `model` is the LLM that powers the agent's reasoning and tool use. Keystroke supports hundreds of models from providers like Anthropic, OpenAI, and Google, chosen by ID in `vendor/model-id` format.

```ts theme={null}
export default defineAgent({
  slug: "researcher",
  systemPrompt: "Research the user's question and cite sources when available.",
  model: "anthropic/claude-sonnet-4.6",
});
```

For the full, current list of model IDs, see the [Keystroke models catalog](https://keystroke.ai/models.md). Copy the **Model ID** column exactly — IDs are opaque catalog strings, not display names you can reformat. Some models use dots in version segments (`anthropic/claude-sonnet-4.6`, `google/gemini-3.5-flash`); others use hyphens (`alibaba/qwen-3-14b`). Do not kebab-case a version number from the model name: `anthropic/claude-sonnet-4-6` is invalid even though the product is called "Claude Sonnet 4.6". Unknown IDs fail at build and deploy time.

The same model string works locally and in the cloud; only how the model is accessed changes:

* **Local:** set a single `AI_GATEWAY_API_KEY` to use any model in the catalog, or set a per-vendor key such as `ANTHROPIC_API_KEY` to call that provider directly.
* **Cloud:** hosted workers route through the platform automatically, so deployed agents need no provider keys.

<Note>
  `keystroke auth login` authenticates you to the platform for deploys and cloud commands. It does not give your local runtime model access; local runs still need `AI_GATEWAY_API_KEY` or a direct provider key.
</Note>

You can use `thinkingLevel` to control the model's reasoning effort when the provider supports it. It defaults to `medium`; valid values are `provider-default`, `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`.

```ts theme={null}
export default defineAgent({
  slug: "planner",
  systemPrompt: "Plan carefully, then give a concise answer.",
  model: "anthropic/claude-sonnet-4.6",
  thinkingLevel: "high",
});
```

## Structured output

By default `agent.prompt()` returns conversational text in `result.messages`. When you call the agent from code (a [workflow](/learn/workflows/build-workflows#agent-steps), [action](/learn/actions/overview), or script) and need a typed object instead, pass an `outputSchema` (Zod) on the call. `outputSchema` is a **per-prompt** option, not a `defineAgent()` field, so the same agent can return free text on one call and structured data on the next. Read the parsed, typed result from `result.output`:

```ts theme={null}
import { z } from "zod";
import researcher from "../agents/signup-researcher";

const Summary = z.object({ company: z.string(), summary: z.string() });

const result = await researcher.prompt({
  message: "Research Acme Corp",
  outputSchema: Summary,
});
if (result.error) throw new Error(result.error);

const { company, summary } = result.output!; // typed { company: string; summary: string }
```

Without `outputSchema`, `result.output` is `undefined` and you read the reply from `result.messages`. Structured output is an in-process TypeScript feature — it is not exposed over the HTTP route or `keystroke agents prompt`.

### Schema design: model the shape precisely

Design the schema around the outcomes you actually expect, not one flat object that tries to cover every case with optional fields. This is both better type safety (each result is exhaustively typed) and it sidesteps a hard provider limit: Anthropic's native structured output rejects schemas with **more than 16 union-typed parameters** — every `.nullable()` / `.nullish()` field compiles to a `T | null` union, so a wide flat object blows past the cap and fails at request time with `Schemas contains too many parameters with union types`.

When a result has variants that carry different fields, use a **discriminated union** keyed on a literal so each branch declares only its own required fields:

```ts theme={null}
const Decision = z.discriminatedUnion("action", [
  z.object({ action: z.literal("drop"), newPrice: z.number(), reasoning: z.string() }),
  z.object({ action: z.literal("no_more_offers"), reasoning: z.string() }),
  z.object({ action: z.literal("payment_plan"), months: z.number(), reasoning: z.string() }),
]);
```

Prefer required fields over `.nullable()`/`.nullish()`, and reach for discriminated unions over large optional-heavy objects. Plain `.optional()` (a field that may be absent) does not create a union, so it does not count toward the limit — but a precise discriminated union is usually the clearer model anyway.

### Structured output with tools

When an agent has tools **and** you pass `outputSchema`, Keystroke runs a multi-step tool loop: the model calls tools, then returns a schema-validated result. Expect at least two LLM steps (tool call + structured result). See the [AI SDK troubleshooting guide](https://ai-sdk.dev/docs/troubleshooting/tool-calling-with-structured-outputs) for the same step-count rule.

Keystroke applies vendor-specific fixes automatically:

| Vendor                         | Behavior                                                                                                                                                                                                                |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Anthropic (Sonnet/Opus)**    | Native structured output compatible with extended thinking; forces a tool call on step 1 so the model cannot skip tools by satisfying the schema early. **Haiku** is excluded — tools + schema may still be unreliable. |
| **OpenAI, Google, xAI**        | Native structured output; no extra guard needed.                                                                                                                                                                        |
| **z-ai (GLM), Alibaba (Qwen)** | No native structured output on the gateway. Keystroke uses a **submit tool** (`submit_structured_output`) for `agent.prompt()` with tools and for all `promptLlm()` calls.                                              |
| **Minimax, DeepSeek**          | Submit-tool path for `promptLlm()`; native structured output for `agent.prompt()` with tools.                                                                                                                           |

Gateway model tags (`reasoning`, `tool-use`, `vision`, …) do **not** indicate structured-output support. Pick models from the [models catalog](https://keystroke.ai/models.md) for pricing and capabilities, but rely on the table above for `outputSchema` reliability.

`outputSchema` works in `agent.prompt()` and workflow `promptLlm()` steps. It is not exposed over HTTP or the CLI.

## Web search

Agents can read information from the live web through two built-in host tools, injected when Keystroke can resolve a web provider:

* `web_search` searches the web by query.
* `web_fetch` fetches readable page text from a URL.

In hosted workers the platform can proxy web search automatically. For local runs, set `EXA_API_KEY` to enable Exa-backed web tools, then make the prompt explicit about when to search:

```ts theme={null}
export default defineAgent({
  slug: "web-researcher",
  systemPrompt:
    "Use web_search before answering factual questions about current companies or events. Use web_fetch to inspect promising sources.",
  model: "anthropic/claude-sonnet-4.6",
});
```

## Ephemeral triggers

Every agent is given two built-in tools (`set_trigger` and `list_triggers`) so it can schedule its own work without a deploy. The agent can create a cron, webhook, or poll trigger on itself, then update, pause, or delete it later. These are *ephemeral* triggers: the agent manages them at runtime and they live in the database, separate from the triggers you write in `src/triggers/`.

These tools are injected automatically; there is no `defineAgent()` option to add them, and they require no configuration. They are how an agent honors a request like "remind me about this in an hour" or "check the deploy every morning and message me if it's red": the agent creates a trigger on itself from inside the conversation instead of needing you to write one.

Ephemeral triggers support the same three kinds as code triggers:

| Kind    | Fires                                    |
| ------- | ---------------------------------------- |
| Cron    | On a schedule                            |
| Webhook | When a matching payload hits an endpoint |
| Poll    | When a scheduled script reports new work |

The agent can give a trigger a `lifecycle` so it stops on its own: `maxExecutions` for a fixed number of runs (a single future reminder is just `maxExecutions: 1`) or `until` for an expiry time. When an ephemeral trigger fires it starts a new agent session and appears in **History** alongside every other run.

When you want a sustained, deploy-time automation instead (a schedule or webhook wired to an agent in code), define it in `src/triggers/`. See [run agents from triggers](/learn/agents/run-agents#run-agents-from-triggers) and the [triggers](/learn/triggers/overview) section.

## Browser use

Browser use means driving a real browser: clicking, filling forms, navigating multi-step flows, and taking screenshots. That goes beyond reading page text with `web_fetch`.

<Note>
  Native browser automation is coming soon. Until then, wrap browser work in a task-specific [action](/learn/actions/agent-tools) or a [workflow](/learn/workflows/overview) step rather than exposing a general browser to the agent.
</Note>

## Sandboxes

Every agent already gets a `/workspace` with built-in `bash`, `read`, `write`, and `edit` tools, running in-process with no VM. `/workspace/agent` persists across sessions (skills, attached files, anything worth keeping); `/workspace/session` is per-session scratch where coding tools default. This handles a surprising amount on its own: manipulating files, processing text and data, and running shell commands and scripts. Many platforms boot a full sandbox VM for any code execution at all; Keystroke gives agents this lightweight bash environment by default, so **most agents never need a sandbox**.

The workspace starts empty unless you attach content:

| Source               | Where it comes from                                                                                             |
| -------------------- | --------------------------------------------------------------------------------------------------------------- |
| Agent files          | `sandbox: defineSandbox({ files: true })` uses `src/files/{agentSlug}/`; `files: "key"` uses `src/files/{key}/` |
| Project skills       | `skills: ["support"]` materializes `src/skills/support/`                                                        |
| Inline sandbox files | `defineSandbox({ files: [...] })` seeds explicit paths in code                                                  |
| Runtime files        | The agent can create and edit files during the session                                                          |

Use `defineSandbox()` to attach project file sets or seed specific files directly in code:

```ts theme={null}
import { defineAgent } from "@keystrokehq/keystroke/agent";
import { defineSandbox } from "@keystrokehq/keystroke/sandbox";

const seeded = defineSandbox({
  files: [{ path: "context/seed.txt", file: "seeded" }],
});

export default defineAgent({
  slug: "sandbox-seeded",
  systemPrompt: "Read files from /workspace before answering.",
  model: "anthropic/claude-sonnet-4.6",
  sandbox: seeded,
});
```

The in-process bash is not a full machine, though. You can easily enable a VM-backed sandbox with `defineSandbox({ mode: "vm" })` when your agent needs capabilities the default workspace can't provide:

| Enable a VM when your agent needs to…         | Example                                                |
| --------------------------------------------- | ------------------------------------------------------ |
| Run real CLI tools and binaries               | `git`, `python`, `ffmpeg`, a package manager           |
| Clone, build, and test a real codebase        | `git clone` a GitHub repo, install deps, run its tests |
| Install dependencies or system packages       | `npm install`, `pip install`, `apt-get`                |
| Run heavy or long-lived processes             | compile a project, start a dev server                  |
| Strongly isolate untrusted code from the host | safely execute arbitrary agent-generated code          |

```ts theme={null}
export default defineAgent({
  slug: "repo-worker",
  systemPrompt: "Clone the repo into /workspace, then make the requested change.",
  model: "anthropic/claude-sonnet-4.6",
  sandbox: defineSandbox({ mode: "vm" }),
});
```

In both modes the agent itself runs in the Keystroke worker and works through its `bash`, `read`, `write`, and `edit` tools; `sandbox.mode` only changes where those tools execute. A `bash` call with `mode: "vm"` runs the command inside the VM and returns its output to the agent, rather than running in-process. The agent reaches into the VM through tools; it never runs inside it.

For everything else, we recommend leaving `mode` unset on `defineSandbox()` (the in-process bash is faster to start and saves you money because you aren't running a VM).

## Next steps

<CardGroup cols={2}>
  <Card title="Run agents" href="/learn/agents/run-agents">
    Prompt agents from the CLI and inspect sessions.
  </Card>

  <Card title="Test agents" href="/learn/agents/test-agents">
    Add tests and local prompts before deploying agent changes.
  </Card>

  <Card title="External channels" href="/learn/agents/external-channels">
    Route Slack messages to an agent.
  </Card>

  <Card title="Agent runs" href="/learn/logs/agent-runs">
    Review conversation history, tool calls, traces, and errors.
  </Card>
</CardGroup>
