> ## Documentation Index
> Fetch the complete documentation index at: https://docs.os.meetluno.ai/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.

**1. Define the agent**

An agent needs a `slug`, `name`, `description`, `systemPrompt`, and `model`. Create a file in `src/agents/` that default-exports the definition.

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

export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Answers customer questions concisely.",
  systemPrompt: "You are a helpful support assistant. Answer concisely.",
  model: "openai/gpt-5.6-sol",
});
```

The `slug` is the stable identity Luno OS uses for discovery, routes, CLI commands, and history.

**2. Deploy it**

Ship the project to the platform so the agent runs in the cloud.

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

Deploy builds and uploads your project, and the CLI now targets it automatically. See [deploy a project](https://docs.os.meetluno.ai/learn/projects/deploy-a-project.md) for project setup.

**3. Use it**

Prompt the deployed agent by its slug, or open **Agents** in the web app.

```bash theme={null}
luno 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](https://docs.os.meetluno.ai/learn/agents/test-agents.md) that prompts the agent or asserts its definition. You can also use the agent directly from Slack (see [external channels](https://docs.os.meetluno.ai/learn/agents/external-channels.md)).

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** | Isolated `/workspace` with `bash` / `read` / `write` / `edit`; `/workspace/agent` shared across sessions, `/workspace/session` per-session | [`sandbox`, `mode`, `setup`, `git`](#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 fields are all you need to start; the rest are optional.

| Option | Required | What it does |
| - | - | - |
| `slug` | Yes | Stable identity used for discovery, routes, CLI commands, and history |
| `name` | Yes | Human-readable name shown in the platform |
| `description` | Yes | Short description shown in the platform |
| `systemPrompt` | Yes | Instructions given to the model |
| `model` | Yes | Exact model id from the [catalog](https://os.meetluno.ai/models.md) in `vendor/model-id` format. See [Models](#models). |
| `thinkingLevel` | No | Reasoning setting. See [Models](#models). |
| `maxSteps` | No | Maximum tool-loop steps per prompt. Defaults to `100`. |
| `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). |
| `modes` / `defaultMode` | No | Named variants (prompt addition, model, tool subset, thinking) a prompt can pick. See [Modes](#modes). |
| `goal` | No | Judge (`model id` or function) and `maxRuns` for prompts sent with a `goal`. See [Goals](#goals). |
| `chat` | No | `false` keeps an internal agent (one that only runs inside a workflow `agentStep`, like an extractor) out of its Space's chat list in the user view. Studio still lists it. Defaults to `true`. |
| `fallbackModels` / `compactionModel` | No | Up to three models tried when `model` fails ([Fallback models](#fallback-models)); the model that compacts long contexts (default `deepseek/deepseek-v4-flash`). |
| `brains` | No | [Brains](https://docs.os.meetluno.ai/learn/brains/overview.md) the agent searches, by slug or `{brain}:{filter}`. |
| `web` / `browser` | No | `false` turns off [web search](#web-search); `true` adds [browser use](#browser-use). |
| `subagents` / `handoffs` | No | How the agent supervises subagents in its `tools`, and the agents it can [hand off](#handoffs) to. |
| `inputProcessors` / `outputProcessors` | No | [Guardrails](#guardrails) on what goes into and comes out of the model. |
| `channels` / `credentials` | No | Where the agent answers in Slack / Teams, and whether tools there use the speaker's own connections (`"speaker"`). See [external channels](https://docs.os.meetluno.ai/learn/agents/external-channels.md). |
| `owner` | No | Email of the person responsible: approval tasks fall back to them when a tool names no approvers. The owner picked in the dashboard wins. |
| `manualMinutes` | No | Minutes a person needs to handle one conversation by hand; Governance uses it for "hours saved". The dashboard value wins. |

> [!NOTE]
>
> Credentials are declared on the actions an agent uses and resolved when those tools run; the agent-level `credentials` option only picks whose user-scoped connections apply in Slack / Teams.

## 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 from an external MCP server, wrapped as app actions | [`MCP tools`](#mcp-tools) |

### Actions as tools

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

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

export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Helps customers using the lookupCustomer tool.",
  systemPrompt: "Help customers. Use lookupCustomer to find account details.",
  model: "openai/gpt-5.6-sol",
  tools: [lookupCustomer],
});
```

Reach for [actions](https://docs.os.meetluno.ai/learn/actions/agent-tools.md) 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 "@meetluno/luno/agent";
import researcher from "./researcher";

export default defineAgent({
  slug: "orchestrator",
  name: "Orchestrator",
  description: "Delegates research tasks to a researcher subagent.",
  systemPrompt:
    "You are an orchestrator. For research tasks, call researcher before answering.",
  model: "openai/gpt-5.6-terra",
  tools: [researcher],
});
```

Subagent calls appear as tool calls in the parent session, and the child agent runs as its own queued session with its own memory. On deployed projects the parent **pauses** while the subagent works (it holds no worker) and resumes with the answer, even across restarts; prompts run inline (a request waiting for the reply) wait for the child instead. If the subagent pauses for a [human approval](#human-approval), the approval shows up in the parent's chat and Slack/Teams thread. Canceling the parent cancels its children. Inspect both in [run history](https://docs.os.meetluno.ai/learn/logs/agent-runs.md). To keep the parent going instead, run the subagent as a [background task](#background-tasks).

#### Supervising subagents

Use `subagents` to control delegation from the parent:

```ts theme={null}
export default defineAgent({
  slug: "orchestrator",
  name: "Orchestrator",
  description: "Coordinates research and writing.",
  systemPrompt: "Delegate research to researcher and writing to writer, then answer.",
  model: "openai/gpt-5.6-terra",
  tools: [researcher, writer],
  subagents: {
    // Recent conversation sent along with every delegation (default: only the task).
    context: { messages: 4 },
    onDelegationStart: ({ agent, prompt, iteration }) => {
      if (agent === "researcher" && iteration > 3) {
        return { proceed: false, rejectionReason: "Research budget used up; answer with what you have." };
      }
      return { prompt: `${prompt}\n\nCite your sources.`, maxSteps: 8 };
    },
    onDelegationComplete: ({ agent, text, success }) =>
      success ? { resultText: text.slice(0, 4000) } : undefined,
  },
});
```

| Hook | Can |
| - | - |
| `onDelegationStart` | Reject the call (the parent model sees the reason), rewrite the prompt, cap the subagent's steps |
| `onDelegationComplete` | Replace the text the parent model sees (summarize, redact, add notes) |

Every subagent result ends with a `[ref: researcher:call_…]` line. The parent can pass earlier refs in the tool's `contextFromRefs` input to give a later subagent those exact results as context (for example, the writer receives the researcher's findings verbatim).

### Workflows as tools

A [workflow](https://docs.os.meetluno.ai/learn/workflows/overview.md) 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 "@meetluno/luno/agent";
import refundOrder from "../workflows/refund-order";

export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Helps customers and can run the refund-order workflow.",
  systemPrompt:
    "Help customers. To issue a refund, call the refund-order tool with the order ID.",
  model: "openai/gpt-5.6-terra",
  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 as a queued child with its normal durability, including sleeps and `hookStep` waits. Inspect the workflow run and the agent session together in [run history](https://docs.os.meetluno.ai/learn/logs/agent-runs.md).

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. Register them as org apps, connect credentials, inspect the live tool list, smoke-test with `integrations execute`, author `app.action` wrappers, then put the **app** (or picked actions) in `tools`.

`integrations actions list` on a custom MCP is **not** the same as listing an official catalog app. It calls the remote `tools/list` and shows every tool the server advertises. Those tools may not exist as TypeScript `app.action(...)` functions yet — smoke-test with `integrations execute`, then wrap what you need.

```bash theme={null}
luno docs search "custom apps MCP"
luno integrations create --mcp https://mcp.deepwiki.com/mcp
luno connect my-org/deepwiki --print-url
luno integrations actions list my-org/deepwiki   # remote tools, not project TS actions yet
luno integrations execute my-org/deepwiki ask_question --input '{"repoName":"facebook/react","question":"…"}'
luno integrations sync my-org/deepwiki
```

```ts theme={null}
import { defineAgent } from "@meetluno/luno/agent";
import { deepwiki } from "../integrations/deepwiki/integration";
// After authoring actions on `deepwiki` via app.action(...):

export default defineAgent({
  slug: "researcher",
  name: "Researcher",
  description: "Answers questions using the DeepWiki MCP app.",
  systemPrompt: "Use DeepWiki tools to answer repo questions.",
  model: "openai/gpt-5.6-sol",
  tools: [deepwiki], // all actions registered on the app
});
```

Do not import `defineMcp` from `@meetluno/luno/agent`. See [custom apps and MCP](https://docs.os.meetluno.ai/learn/credentials/custom-integrations.md).

> [!NOTE]
>
> This is the client side of MCP: your project using an external MCP server. For the reverse, building Luno OS agents, workflows, and triggers from an MCP-capable agent like ChatGPT or Claude, see [MCP for agents](https://docs.os.meetluno.ai/build-with-ai/mcp-for-agents.md).

### Human approval

Set `needsApproval: true` on an action or tool that should not run without a person saying yes (refunds, deletes, outbound emails). When the model calls it, the session stays `running`, the call waits as a pending approval (`luno agents approvals list`), and nothing runs until someone decides:

```ts theme={null}
export default defineAction({
  slug: "issue-refund",
  name: "Issue refund",
  description: "Refund an order.",
  input: z.object({ orderId: z.string(), amount: z.number() }),
  output: z.object({ refundId: z.string() }),
  needsApproval: true,
  run: async ({ orderId, amount }) => refunds.create({ orderId, amount }),
});
```

Decide from the dashboard, the chat (Slack/Teams buttons), the CLI or the API:

```bash theme={null}
luno agents approvals list <agent> <session-id>
luno agents approvals approve <agent> <session-id> <approval-id>
luno agents approvals deny <agent> <session-id> <approval-id> --reason "already refunded"
```

Once every pending call is decided, the session resumes on its own: approved calls run, denied calls reach the model as errors carrying your reason, and the agent finishes its reply. Sessions must run queued (the default in deployed projects) for the pause to survive restarts. A prompt sent with `outputSchema` pauses the same way and returns its structured `output` once the paused calls are decided.

#### Edit before approving

Add `editable: true` when the approver should be able to fix the call instead of only saying yes or no (a draft email, a post, an amount):

```ts theme={null}
needsApproval: { editable: true },                          // anyone who may decide
needsApproval: { approvers: ["$owner"], editable: true },   // with approvers
```

The web app chat shows **Edit** on the approval card (the input as JSON), Slack shows **Edit and approve** (a form with the JSON) and Teams adds an editable field with **Approve with edits**. The API takes `editedInput` with an approve: `POST /agents/{agent}/sessions/{sessionId}/approvals/{approvalId}` with `{ "decision": "approved", "editedInput": { ... } }`. The edited input is checked against the action's input schema before anything is recorded (an invalid one, or an edit on a tool without `editable`, is rejected with `400`). The call runs with the edited input, the agent is told a person changed it, and both the original and the edited input stay in the approval history.

#### Decide several at once

When an agent pauses on several calls (a batch of drafts, a list of backfills), the web app chat shows a **Select all** bar with **Approve selected** and **Reject selected**. Through the API, send them in one request:

```bash theme={null}
curl -X POST "$LUNO_URL/agents/<agent>/sessions/<session-id>/approvals" \
  -H "content-type: application/json" \
  -d '{ "decisions": [
        { "approvalId": "a1", "decision": "approved" },
        { "approvalId": "a2", "decision": "approved", "editedInput": { "subject": "Fixed" } },
        { "approvalId": "a3", "decision": "denied", "reason": "duplicate" } ] }'
```

It is all or nothing: if you may not decide one of them, or one edited input is invalid, nothing is recorded and the error names that approval. The agent resumes once, after all of them.

#### Who can approve

`needsApproval: true` without approvers lets the person who owns the session (who started it) or a project admin (the project **Admin** role, or an org owner/admin) decide; in Slack and Teams, any organization member who may approve for the agent (matched by the chat account's email). Everyone also needs the agent's approve list (or use list) — see [governance](https://docs.os.meetluno.ai/learn/projects/governance.md#sessions-and-approvals). To name who decides, list the approvers (emails of organization members, or Luno user ids):

```ts theme={null}
needsApproval: { approvers: ["finance-lead@acme.com", "cfo@acme.com"] },
```

Two tokens name people per call instead of up front:

| Token | Who decides |
| - | - |
| `$owner` | The user who started the session (a web app, API or CLI conversation). Chat-channel and trigger sessions have no owner. |
| `$speaker` | The organization member whose Slack or Teams message led to the call (matched by the chat account's email). |

```ts theme={null}
// A personal assistant: whoever asked confirms the email before it is sent.
needsApproval: { approvers: ["$speaker", "$owner"] },
```

A token that resolves to nobody for a call (no owner, an author who is not a member) names nobody:
it never falls back to "anyone may decide". Mix tokens with emails to keep a fallback approver.
Any other `$…` value is rejected when the tool is defined.

You can also pick approvers in the web app: open the agent, **Configure** rail, **Approvals**, and choose organization members for each tool that needs approval (requires the project Admin role, `project:manage`). The rule is simple: **approvers picked in the web app replace the ones in code** for that tool; clear the picker to fall back to the code. The check runs when someone decides, against the current settings. An agent-level **Who can approve** list (project [Governance](https://docs.os.meetluno.ai/learn/projects/governance.md) settings) applies on top: the decider must be on it too.

* The API, CLI and web app answer `403` to anyone else.
* In Slack and Teams, the card lists the approvers. A click maps the chat user's email to an organization member (Slack needs the `users:read.email` scope, which the Luno app requests); anyone else, or a chat account whose email is not a member's, gets a notice and the buttons stay for the real approvers.
* WhatsApp never gets approval buttons: the customer is told the request awaits approval in Luno.
* Every decision records who decided (the Luno user id, or `slack:<id>` / `teams:<id>` when no member matched), where (`dashboard`, `cli`, `api`, `slack`, `teams`), when and the reason. The chat view shows it under the message; `luno agents approvals list <agent> <session-id> --all` and `GET /agents/{agent}/sessions/{sessionId}/approvals?status=all` return it.

## Signals: messages into a running conversation

Send a message into an existing session from outside it — a build result, a webhook, a teammate's note. The agent sees it as `[message] <text>` in the same conversation and memory.

```bash theme={null}
luno agents sessions signal <agent> <session-id> --message "Deploy finished: 12 tests passed"
luno agents sessions signal <agent> <session-id> --message "Stop, use staging" --mode inject
```

```ts theme={null}
await client.agents.sendSignal("support", sessionId, { message: "Customer replied", mode: "queue" });
// → { woke: true } when the session was idle and a new turn started
```

* `queue` (default): delivered when the current prompt finishes.
* `inject`: a running prompt reads it before its next step, without stopping.
* An idle session wakes up and answers right away, in the same thread and with the same user's memory. Several pending signals arrive as one message.

HTTP: `POST /agents/{agent}/sessions/{sessionId}/signals` with `{ message, mode? }`.

Only the session's owner or a project admin can send signals into it (`403` otherwise); sessions with no owner (a chat channel, a trigger) are a project admin's only. Every signal records who sent it (`user:<id>`).

Triggers can deliver signals too: attach a source with `session` and `message` instead of `prompt`, and each event goes into the session it names (`[<trigger slug>] <message>`). See [deliver into an existing session](https://docs.os.meetluno.ai/learn/triggers/advanced-triggers.md#deliver-into-an-existing-session).

## Background tasks

Wrap a subagent or workflow tool in `background()` when the agent should not wait for it. The call returns at once with a task id, the agent keeps working (or finishes its reply), and the result arrives later as a [signal](#signals-messages-into-a-running-conversation) from `[background:<tool>]`:

```ts src/agents/assistant.ts theme={null}
import { background, defineAgent } from "@meetluno/luno/agent";
import researcher from "./researcher";
import nightlyReport from "../workflows/nightly-report";

export default defineAgent({
  slug: "assistant",
  name: "Assistant",
  description: "Starts long research in the background and keeps chatting.",
  systemPrompt:
    "Start research with researcher and tell the user it is running. When a [background:researcher] message arrives, summarize it.",
  model: "anthropic/claude-sonnet-4.6",
  tools: [background(researcher), background(nightlyReport)],
});
```

* The model sees `Started researcher in the background (task …)` as the tool result; the child runs as its own queued session or workflow run.
* When the child finishes or fails, its result is queued into the parent session: a running parent reads it after its current prompt, an idle one wakes up and answers in the same chat thread (Slack, Teams) and with the same user's memory.
* Background tasks need queued execution (the default in deployed projects). A prompt run inline (a request waiting for the reply) waits for the tool like a normal subagent or workflow call.
* A supervisor's `onDelegationComplete` hook does not see background results; they reach the parent as messages.

## Goals

Send a prompt with a `goal` and the agent keeps working until the goal is met. After every prompt in that session a judge checks the agent's latest reply against the goal. While it is not met, the judge's feedback goes back into the session as a `[goal]` message and the agent continues, up to `maxRuns` judged prompts.

```bash theme={null}
luno agents prompt writer --message "Draft the launch post" --goal "A post under 200 words with a clear call to action"
```

```ts theme={null}
await client.agents.prompt("writer", { message: "Draft the launch post", goal: "A post under 200 words with a CTA" });
const { session } = await client.agents.getSession("writer", sessionId);
// session.goal → { objective, status: "active" | "done" | "exhausted", runs, feedback }
```

Configure the judge on the agent. It is a model id (one structured-output call per judged prompt; pick a cheap model) or your own function. Without `goal`, the agent's own model judges and `maxRuns` is `5`.

```ts theme={null}
defineAgent({
  // ...
  goal: { judge: "deepseek/deepseek-v4-flash-0731", maxRuns: 5 },
});

defineAgent({
  // ...
  goal: {
    judge: ({ goal, lastReply, messages }) =>
      lastReply.includes("CTA") ? { done: true } : { done: false, feedback: "Add a call to action." },
  },
});
```

* A new `goal` replaces the session's previous one. The goal stops as `done` when the judge says so and as `exhausted` after `maxRuns` judged prompts.
* Prompts that end in an error are not judged. If the judge itself fails, the goal stays `active` and the agent stops until the next prompt.
* Goals need queued execution; follow-ups reply in the same chat thread as the prompt that set the goal.

## Multi-agent patterns

Several agents can work on one task. Pick the pattern by who decides what runs next:

| Pattern | Who decides | Use it for |
| - | - | - |
| Supervisor | The parent agent's model | Open-ended delegation: add agents to `tools` as [subagents](#subagents-as-tools) and steer delegation with the [`subagents` hooks](#supervising-subagents). |
| Workflow | Your code | A known sequence: chain [agent steps](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#agent-steps) with actions, branches, and loops. |
| Council | Your code | Independent answers to the same question, combined by one agent: [`councilStep()`](https://docs.os.meetluno.ai/learn/workflows/build-workflows#council) in a workflow. |
| Handoff | The current agent's model | Passing the conversation itself to a specialist, who answers the user from then on: [`handoffs`](#handoffs). |

### Handoffs

A subagent works for its parent and reports back. A handoff instead gives the conversation away: the target agent answers the user in the same session, with the whole transcript, and keeps answering later messages.

```ts src/agents/triage.ts theme={null}
import billing from "./billing";

export default defineAgent({
  slug: "triage",
  name: "Triage",
  description: "Front desk.",
  systemPrompt: "Route billing questions to billing with handoff_to_billing.",
  model: "anthropic/claude-sonnet-4.6",
  handoffs: [billing], // or slugs: ["billing"], so two agents can hand back and forth
});
```

* Each target becomes a `handoff_to_<slug>` tool with a `reason` and an optional `summary`. Calling it ends the current turn; the target replies right away, seeing `[handoff:triage] …` with the reason and summary.
* Later prompts to the session — through the original agent's API route, a chat thread (Slack, Teams), a signal, or a trigger — go to the target. Chat replies keep landing in the same thread.
* Handing off back to the agent that started the session returns the session to it.
* In direct messages to the shared Slack / Teams bot, the agents a workspace lets people [switch to](https://docs.os.meetluno.ai/learn/agents/external-channels.md#several-agents-in-one-workspace) are added as handoff targets of whoever answers, on top of the ones in code.
* The session stays listed under the agent that started it, and memory stays that agent's notes about the user, so the target sees the same per-user notes.
* Handoffs need queued execution (the default in deployed projects). Like any signal, the target's first reply runs without the user's personal credentials; later prompts carry them as usual.

## Skills and files

Skills are reusable instructions in `src/skills/`. Files are static project context in `src/files/`. Both materialize under **`/workspace/agent`** before a prompt runs, so the agent can read them like local files. That directory persists across sessions for shared context — not for git checkouts or package installs (use `/workspace/session` for those). See [Sandboxes](#sandboxes).

```ts theme={null}
import { defineSandbox } from "@meetluno/luno/sandbox";

export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Answers using the support skill and attached product docs.",
  systemPrompt:
    "Read /workspace/agent/product-guide.md and the support skill before answering.",
  model: "openai/gpt-5.6-sol",
  skills: ["support"],
  sandbox: defineSandbox({ files: "support" }),
});
```

`files` is a path (or list of paths) under `src/files/`: a set directory, multiple sets, or specific files.

```ts theme={null}
export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Answers using the support handbook file set.",
  systemPrompt: "Read the support handbook before answering.",
  model: "openai/gpt-5.6-terra",
  sandbox: defineSandbox({ files: "support-handbook" }),
});
```

```ts theme={null}
sandbox: defineSandbox({
  files: ["planetscale", "support/product-guide.md"],
}),
```

See [project skills](https://docs.os.meetluno.ai/learn/skills/overview.md) and [files](https://docs.os.meetluno.ai/learn/files/overview.md) for the file formats.

## Memory

Memory is what lets an agent remember. It is on by default and has three parts:

| Part | What it does |
| - | - |
| **Session history** | The messages of one conversation, so follow-up prompts have context |
| **Notes per user** | Working memory the agent keeps about each person it talks to (name, preferences, ongoing work) with its `updateWorkingMemory` tool, shown at the start of every conversation with that person |
| **Recall** | Relevant messages from the same person's earlier conversations, found by meaning and added to the prompt |

Each user gets their own notes and recall: a dashboard or API user by their account, a Slack or Teams user by their chat id. Runs without a user (triggers, schedules, API keys) write the agent's **shared notes** instead, which user conversations can read but never change, so nothing one user says leaks to another. Memory is stored in your project's database, and you can read or clear it per user from the agent's **Memory** tab. Each user sees their own notes and the shared notes there; other users' notes are visible only to project admins (the project **Admin** role, or org owners/admins), who are also the only ones who can clear them.

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",
  name: "One-shot classifier",
  description: "Classifies a message and returns only the label.",
  systemPrompt: "Classify the message and return only the label.",
  model: "deepseek/deepseek-v4-flash",
  memory: false,
});
```

Memory is **agent-curated**: you don't pre-load it from the definition. Put stable, author-provided context in the [`systemPrompt`](#models) or in [files](#skills-and-files). To shape what the agent tracks, give it a markdown template:

```ts theme={null}
export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Helps customers and remembers their setup.",
  systemPrompt: "Help the customer. Keep their account details current in your notes.",
  model: "openai/gpt-5.6-sol",
  memory: {
    workingMemory: {
      template: "# Customer\n- Name:\n- Plan:\n- Integrations:\n\n# Open issues\n",
    },
    semanticRecall: { topK: 5 },
  },
});
```

| Option | Default | What it does |
| - | - | - |
| `workingMemory` | on | `false` turns notes off; `{ template }` sets the markdown the agent fills in |
| `semanticRecall` | on | `false` turns recall off; `{ topK, messageRange }` sets how many matches (default 3) and how many neighbouring messages around each (default 1) are added |
| `observational` | off | `true` condenses long sessions into observations instead of compacting them; see [observational memory](#observational-memory) |

Continue a conversation by passing the same `sessionId` to the next prompt. See [run agents](https://docs.os.meetluno.ai/learn/agents/run-agents.md) for session commands.

Long sessions are compacted automatically when they near the model's context window: the older part of the conversation is replaced by one summary. To control what of the history reaches the model, add a [processor](#guardrails) to `inputProcessors`: it sees the session history and recalled messages on every step.

```ts theme={null}
import { TokenLimiterProcessor, ToolCallFilter } from "@meetluno/luno/processors";

export default defineAgent({
  // ...
  inputProcessors: [
    new ToolCallFilter({ exclude: ["web_search"] }), // drop old search calls and results
    new TokenLimiterProcessor({ limit: 50_000 }), // cap the history sent per step
  ],
});
```

### Observational memory

For agents with long-running sessions (an assistant in a busy Slack thread, a support agent working one ticket for days, a tool-heavy agent that reads large pages), turn on **observational memory**. A second, cheap model watches the conversation and keeps a dated log of what happened; once the history the model has not observed yet passes a token threshold, the observed messages leave the prompt and the log takes their place. When the log itself grows large, it is condensed again.

```ts theme={null}
export default defineAgent({
  // ...
  memory: {
    observational: true, // or { model, messageTokens, observationTokens }
  },
});
```

| Option | Default | What it does |
| - | - | - |
| `model` | `deepseek/deepseek-v4-flash-0731` | Model that writes and condenses the observations |
| `messageTokens` | `30000` | Unobserved history (tokens) that triggers observing it |
| `observationTokens` | `40000` | Size of the observation log (tokens) that triggers condensing it |

* It replaces compaction for that agent: instead of one summary when the context is nearly full, the prompt stays small all the time, which also keeps it cheaper and more cacheable.
* Observations are per session. To remember things across sessions, keep notes (`workingMemory`) and recall on.
* The session transcript is not changed: the chat and the dashboard still show every message.
* Cost: extra calls to the observation model, roughly one each time a few thousand tokens of new conversation build up, billed like any other model usage. With the default model this is usually a small fraction of what the agent's own model costs for the same history. If the observation model fails, the prompt still runs, with the history it has not observed yet.
* Attachments are not sent to the observation model; it sees their names.

## Models

The `model` is the LLM that powers the agent's reasoning and tool use. Luno OS 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",
  name: "Researcher",
  description: "Researches questions and cites sources when available.",
  systemPrompt: "Research the user's question and cite sources when available.",
  model: "openai/gpt-5.6-sol",
});
```

### Model selection guide

Approximate list prices are USD per 1M tokens (input / output), from the live gateway catalog. Any catalog ID works as `model`.

| Capability | Model IDs | Approx. \$/1M in/out | Notes |
| - | - | - | - |
| Everyday | `openai/gpt-5.6-terra`, `anthropic/claude-sonnet-5.5` | Terra \~$2/$12 · Sonnet 5.5 \~$2/$10 | Routine agents, tools, high volume |
| Stronger | `anthropic/claude-opus-5.5`, `openai/gpt-5.6-sol` | Opus 5.5 \~$4/$20 · Sol \~$4/$20 | Harder reasoning and long-horizon tasks |
| Hardest | `anthropic/claude-fable-5.1` | \~$10/$50 | The most difficult work |
| Budget | `deepseek/deepseek-v4.1-flash`, `openai/gpt-5.6-luna` | DeepSeek \~$0.08/$0.30 · Luna \~$0.20/$1.20 | Simple, high-volume or one-off calls |
| Alt coding | `zai/glm-5.2` | \~$0.80/$2.55 | Coding and long-context work |

Model ids change as providers ship new generations — prefer the live catalog when picking an id. These facts were last reviewed in October 2026.

For the full, current list of model IDs, see the [Luno OS models catalog](https://os.meetluno.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 (`openai/gpt-5.6-sol`, `google/gemini-3.5-flash`); others use hyphens (`alibaba/qwen-3-14b`). Do not kebab-case a version number from the model name: `openai/gpt-5-6-sol` is invalid even though the product is called "GPT 5.6 Sol". Unknown IDs fail at build and deploy time.

Hosted workers route through the platform automatically, so deployed agents need no provider keys. To run cloud inference on your own provider API keys instead of platform credentials, connect them in [Managed services](https://docs.os.meetluno.ai/learn/settings/managed-services.md) — not as app credentials.

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",
  name: "Planner",
  description: "Plans carefully with higher reasoning effort.",
  systemPrompt: "Plan carefully, then give a concise answer.",
  model: "openai/gpt-5.6-terra",
  thinkingLevel: "high",
});
```

### Fallback models

List up to three `fallbackModels`. When a call to `model` fails (provider outage, rate limit), the agent retries the same step on the next model in order. Usage is billed to the model that actually answered.

```ts theme={null}
export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Answers support questions.",
  systemPrompt: "Help the customer.",
  model: "anthropic/claude-sonnet-4.6",
  fallbackModels: ["openai/gpt-5.6-terra", "google/gemini-3.5-flash"],
});
```

### Per-prompt model

A prompt can run on a different catalog model without changing the agent: pass `model` to `agent.prompt()`, in the HTTP prompt body, to the SDK, or with `luno agents prompt --model`. It wins over the agent's and the mode's `model`; `fallbackModels` still apply. Unknown ids are rejected with `400`.

## Modes

Modes are named variants of one agent — say, `plan` that only reads and thinks harder, and `build` that edits. Each prompt runs in one mode; the session remembers the last one, so later prompts that name none stay in it.

```ts theme={null}
export default defineAgent({
  slug: "coder",
  name: "Coder",
  description: "Plans and implements changes.",
  systemPrompt: "You work on the user's codebase.",
  model: "anthropic/claude-sonnet-5",
  tools: [searchDocs, openPullRequest],
  modes: {
    plan: {
      systemPrompt: "Plan only. Do not change files; end with a numbered plan.",
      model: "openai/gpt-5.6-sol",
      tools: ["search-docs"],
      thinkingLevel: "high",
    },
    build: {},
  },
  defaultMode: "plan",
});
```

| Mode option | What it does |
| - | - |
| `systemPrompt` | **Appended** to the agent's `systemPrompt` (after a blank line) while the mode is active. |
| `model` | Replaces `model`. A per-prompt `model` still wins. |
| `tools` | Keeps only these `tools` entries, by action / workflow / agent / integration slug, MCP `key` or tool `name`. Built-ins (workspace, skills, web, Brains) stay. |
| `thinkingLevel` | Replaces `thinkingLevel`. |

Mode names are lowercase (`a-z`, digits, `-`, `_`). `defaultMode` defaults to the first mode. `defineAgent` rejects unknown tool names and malformed model ids, and the build checks mode models against the catalog.

A prompt picks its mode with `mode`: `agent.prompt({ message, mode: "build" })`, `{ "message": "…", "mode": "build" }` on `POST /agents/{slug}`, or `luno agents prompt coder --message "…" --mode build`. The resolution order is the prompt's `mode`, then the session's last mode, then `defaultMode`; an unknown mode is a `400`. In the dashboard, agents with modes show a mode and a model picker in the chat composer.

## Guardrails

Processors check or change what goes into and comes out of the model. `inputProcessors` run on the user's message before the model sees it; `outputProcessors` run on the reply. Ready-made ones come from `@meetluno/luno/processors`; the ones that ask an LLM take `model: gatewayModel("vendor/model")`, so the check goes through Luno's gateway and is metered like any other call.

```ts theme={null}
import { defineAgent } from "@meetluno/luno/agent";
import {
  gatewayModel,
  PIIDetector,
  PromptInjectionDetector,
} from "@meetluno/luno/processors";

const checker = gatewayModel("deepseek/deepseek-v4-flash");

export default defineAgent({
  slug: "support",
  name: "Support",
  description: "Answers support questions.",
  systemPrompt: "Help the customer.",
  model: "anthropic/claude-sonnet-4.6",
  inputProcessors: [new PromptInjectionDetector({ model: checker, strategy: "block" })],
  outputProcessors: [new PIIDetector({ model: checker, strategy: "redact" })],
});
```

| Processor | What it does |
| - | - |
| `PromptInjectionDetector` | Blocks or rewrites jailbreak and prompt-injection attempts |
| `ModerationProcessor` | Flags or blocks harmful content by category |
| `PIIDetector` | Detects personal data and blocks, redacts or flags it |
| `LanguageDetector` | Detects the language and can translate to a target language |
| `SystemPromptScrubber` | Stops the reply from leaking the system prompt |
| `RegexFilterProcessor` | Blocks or redacts text matching your patterns (no LLM call) |
| `UnicodeNormalizer`, `TokenLimiterProcessor`, `TokenCostControl` | Normalize input, cap tokens, cap cost |
| `ToolCallFilter` | Drops earlier tool calls and results (all, or by tool name) from what the model sees |

When a guardrail blocks a message, the agent does not call the model; the reply explains it was blocked and the session records why. You can also write your own processor (an object with an `id` and a `processInput` or `processOutputResult` function).

### Guardrails inside a workflow step

Text that never reaches an agent (a PDF, an email body, a form answer) can go through the same PII and prompt-injection detectors with `guardText`, in a workflow step or action:

```ts theme={null}
import { GuardBlockedError, gatewayModel, guardText } from "@meetluno/luno/processors";

const { text, findings } = await guardText(resumeText, {
  model: gatewayModel("deepseek/deepseek-v4-flash"),
  injection: "block", // or "detect": report only
  pii: "redact", // or "detect": report only, text unchanged
});
```

`findings` lists what was flagged (`{ guard: "pii", type, confidence, start, end }` or `{ guard: "injection", type, confidence, reason }`), never the PII values themselves. Injection runs first, so blocked text never reaches the PII check. It throws `GuardBlockedError` (with `findings`) when `injection: "block"` flags the text or flagged PII cannot be redacted, and a plain `Error` when the detection model fails: text that could not be checked is never passed on. Each enabled guard is one model call.

## Structured output

By default `agent.prompt()` returns conversational text in `result.messages`. When you call the agent from code (a [workflow](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#agent-steps), [action](https://docs.os.meetluno.ai/learn/actions/overview.md), 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 over HTTP, the SDK and the CLI

Outside TypeScript, send `outputSchema` as a **JSON Schema** object (`"type": "object"`, at most 32 KB) in the prompt body. The prompt is queued as usual (`202` with `sessionId` and `runId`); once the session completes, the session detail (`GET /agents/{slug}/sessions/{sessionId}`, with messages included) carries the validated result as `output`:

```bash theme={null}
curl -X POST "$LUNO_URL/agents/triage" -H "content-type: application/json" -d '{
  "message": "Login is broken for every user",
  "outputSchema": {
    "type": "object",
    "properties": { "priority": { "type": "string", "enum": ["low", "high"] } },
    "required": ["priority"]
  }
}'
```

```ts theme={null}
const { sessionId } = await client.agents.prompt("triage", { message, outputSchema });
// …once the session is no longer running:
const { output } = await client.agents.getSession<{ priority: "low" | "high" }>("triage", sessionId);
```

```bash theme={null}
luno agents prompt triage --message "Login is broken" --wait \
  --output-schema '{"type":"object","properties":{"priority":{"type":"string"}},"required":["priority"]}'
# or --output-schema-file ./triage-output.json; with --wait prints just the parsed output
```

### 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. OpenAI/Azure strict mode requires every property key in `required` — Zod `.optional()` / `.nullish()` omit keys and the API rejects the schema before the model runs. Luno OS rewrites those wrappers to `.nullable()` on the wire so calls still succeed, but authoring with `.nullable()` (or a discriminated union) matches what providers return and stays under Anthropic's union-parameter limit more predictably.

### Structured output with tools

When an agent has tools **and** you pass `outputSchema`, Luno OS 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.

Luno OS 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. Luno OS uses a **submit tool** (`submit_structured_output`) for `agent.prompt()` with tools and for all workflow `llmStep()` calls. |
| **Minimax, DeepSeek** | Submit-tool path for `llmStep()`; 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://os.meetluno.ai/models.md) for pricing and capabilities, but rely on the table above for `outputSchema` reliability.

`outputSchema` works in `agent.prompt()` and workflow `llmStep()` steps (`agentStep` takes `output`). Over HTTP and the CLI, pass it with a prompt (`outputSchema` in the request body, or `luno agents prompt --output-schema` / `--output-schema-file`); the validated result is returned as `output`.

## Web search

Agents can read information from the live web through two built-in host tools, injected when Luno OS 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. Make the prompt explicit about when to search:

```ts theme={null}
export default defineAgent({
  slug: "web-researcher",
  name: "Web researcher",
  description: "Uses web search and fetch for current factual questions.",
  systemPrompt:
    "Use web_search before answering factual questions about current companies or events. Use web_fetch to inspect promising sources.",
  model: "google/gemini-3.5-flash",
});
```

## Dashboard overrides

Code holds an agent's base values. In Studio, the agent's **Configuration** tab can override its instructions, model, autonomy, max steps, and turn off tools the code declares. Changes are saved as a draft and **published** as a new version (needs the project `project:deploy` permission); the runtime applies the published override on top of the code from the next prompt, including workflow agent steps and triggers. **Reset to code** drops it.

Autonomy is **Acts on its own** (`as_coded`: only tools marked `needsApproval` wait for a person) or **Always asks** (`always_ask`: every tool declared in code waits for approval). Before you change an agent's prompt or tools in code, check its Configuration tab: a published override wins over what you deploy.

An override model, like a per-prompt `model`, must be in the organization's catalog (Governance → Models); the `model` in code is not checked against it.

A workflow [agent step](https://docs.os.meetluno.ai/learn/workflows/build-workflows.md#agent-steps) can be overridden the same way for that step only (see there).

## Workflow visibility

Deployed agents receive a read-only `list_workflows` tool that lists project-defined workflows
which invoke them, along with the deployed triggers that start each workflow. Trigger details
include configured schedules and timezones plus live active, disabled, or completed status. This
lets an agent recognize that it may be running as one step in a larger workflow and understand how
often that workflow is configured to run. Listing a workflow does not make that workflow callable
by the agent; callable workflows must still be included in `defineAgent({ tools })`.

You can inspect the same relationship from the CLI:

```bash theme={null}
luno agents workflows list <agent-slug>
```

## 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/`.

`list_triggers` also reports project-defined triggers that invoke the agent directly or through a
workflow containing it. Workflow triggers are identified by `targetKind: "workflow"` and include
the workflow slug.

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 |

Ephemeral poll triggers run a [code mode](#code-mode) script on each tick. The script can call the
agent's tools; its last non-empty line of JSON becomes the trigger payload. Empty output or `null`
skips the run.

For webhook triggers, pass `endpoint` plus an optional `payload` matcher — a shallow map of dot-paths to exact values (e.g. `{ "type": "invoice.paid" }`). Omit `payload` to accept any JSON body. That matches the role of `defineWebhookSource({ payload })` in project code: the matcher is the gate for which deliveries fire the agent. `list_triggers` returns the compiled `payload` schema for webhook triggers.

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](https://docs.os.meetluno.ai/learn/agents/run-agents.md#run-agents-from-triggers) and the [triggers](https://docs.os.meetluno.ai/learn/triggers/overview.md) 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](https://docs.os.meetluno.ai/learn/actions/agent-tools.md) or a [workflow](https://docs.os.meetluno.ai/learn/workflows/overview.md) step rather than exposing a general browser to the agent.

## Sandboxes

Every agent already gets a `/workspace` with built-in `bash`, `read`, `write`, and `edit` tools, running in-process with no VM. The workspace has two mounts:

| Path | Lifetime | What belongs here |
| - | - | - |
| `/workspace/agent` | Persists across sessions for that agent (**shared filesystem**) | Deployed [files](https://docs.os.meetluno.ai/learn/files/overview.md), [skills](https://docs.os.meetluno.ai/learn/skills/overview.md), and other small artifacts the agent wants to **share across sessions** |
| `/workspace/session` | Ephemeral per session | Scratch, clones, installs, builds, and coding-tool defaults |

**Do not** put git repositories or package installs under `/workspace/agent`. That shared filesystem is for files and skills the agent reuses across prompts — not a checkout or `node_modules`. Clone and install under `/workspace/session` (or another session path you choose). Env `setup` installs belong on the **root filesystem** (see [Env setup](#env-setup-setup)), not under `/workspace/agent`.

This default workspace 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; Luno OS gives agents this lightweight bash environment by default, so **most agents never need a VM**.

The workspace starts empty unless you attach content:

| Source | Where it comes from |
| - | - |
| Agent files | `sandbox: defineSandbox({ files: "key" })` uses `src/files/{key}/`; arrays attach multiple sets or specific paths under `src/files/` |
| Project skills | `skills: ["support"]` materializes `src/skills/support/` under `/workspace/agent/skills/` |
| Inline sandbox files | `defineSandbox({ files: [{ path, file }] })` seeds explicit content in code |
| Runtime files | The agent can create and edit files during the session (`/workspace/session` for scratch; `/workspace/agent` only for cross-session shares) |

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

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

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

export default defineAgent({
  slug: "sandbox-seeded",
  name: "Sandbox seeded",
  description: "Reads seeded context files from the sandbox workspace.",
  systemPrompt: "Read files from /workspace/agent before answering.",
  model: "zai/glm-5.2",
  sandbox: seeded,
});
```

### Code mode

In the default in-process workspace, an agent can use **code mode** to call its tools from a
JavaScript or TypeScript script. This is useful when the agent needs to call several tools, loop
over results, filter data, or combine tool output without sending every intermediate result back
through the model.

Code mode is available automatically—there is nothing to add to `defineAgent()`. The agent runs a
script with `js-exec` through its built-in `bash` tool and calls only the tools already available to
that agent:

```bash theme={null}
js-exec -c '
  const customer = await tools["lookup-customer"]({ email: "ada@example.com" });
  if (customer.openInvoices > 0) {
    await tools["send-reminder"]({ email: "ada@example.com" });
  }
'
```

Tool names containing hyphens require bracket notation (`tools["tool-name"]`). Tool inputs still
use their normal schemas, credentials resolve normally, and a script cannot call tools that are not
attached to the agent. Errors from a tool fail the script and are returned to the agent.

> [!NOTE]
>
> The `js-exec` tool bridge is available in the default in-process workspace, not in VM-backed
> sandboxes. In a VM, use the installed `node` or `python` runtime for scripts and call agent tools
> directly from the model.

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`, Playwright/Chromium |
| 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",
  name: "Repo worker",
  description: "Clones and edits repositories in a VM sandbox.",
  systemPrompt: "Clone the repo into /workspace/session, then make the requested change.",
  model: "openai/gpt-5.6-sol",
  sandbox: defineSandbox({ mode: "vm" }),
});
```

### Hosted VM resources

Hosted VMs use a small CPU/memory request with **no hard limits** (they burst as needed). Author `size` (`small` / `medium` / `large`) is accepted for backward compatibility but **ignored** on hosted cloud. Local microsandbox (`pnpm dev`) may still map size presets to guest resources.

Billable VM time is charged per second of sandbox wall-clock at a flat rate. Persistent agent files live on the **shared filesystem** at `/workspace/agent`; session scratch is ephemeral under `/workspace/session`.

The `bash` tool accepts optional `timeoutMs` so long installs/builds can run past the 180s default (hard-capped at 10 minutes). For **servers that must keep running**, background them (`nohup … &` + log + health poll) — do not block `bash` on them.

### VM credentials and GitHub

Credential env injection and built-in GitHub clone are **VM-only**. Attach credentials on the sandbox, map them into env, and optionally bootstrap a repository with `git.clone`.

Pass an app's `credential` — for example a GitHub app you authored with `defineIntegration` (see [custom integrations](https://docs.os.meetluno.ai/learn/credentials/custom-integrations.md)).

```ts theme={null}
import { github } from "../integrations/github/integration";

sandbox: defineSandbox({
  mode: "vm",
  credentials: [github.credential] as const,
  env: ({ github }) => ({
    // Optional aliases — GH_TOKEN / GITHUB_TOKEN are injected automatically when `git` is set
    CUSTOM_GITHUB_TOKEN: github.accessToken,
  }),
  git: {
    clone: {
      repository: "owner/repo",
      credential: "github",
      // path defaults to /workspace/session/repo
      // branch: "main",          // optional branch or tag
      // commit: "abcdef01…",     // optional full 40-char SHA (takes precedence over branch)
      // shallow: false,          // default true (`--depth 1`); set false for full history
    },
  },
  setup: [
    "npm i -g @posthog/cli",
  ],
}),
```

With the built-in `git.clone` helper, Luno OS clones into **`/workspace/session`** (default `/workspace/session/repo`). Clones are shallow (`--depth 1`) by default for fast startup; set `shallow: false` for full history. Use `branch` for a branch/tag tip, or `commit` for an exact full 40-character SHA (GitHub supports fetch-by-SHA; abbreviated SHAs and `git clone --branch <sha>` do not). Remounts and later prompts in the same session skip re-clone when the repo already exists.

If you clone or install yourself from `bash` or a skill (instead of `git.clone` / `setup`), keep that work under `/workspace/session` — **never** clone repos or install packages into `/workspace/agent`. The shared filesystem is only for shared files and skills across sessions.

Secrets are merged into every VM exec (not create-time sandbox env). Tokens are model-visible inside the VM so `git` (and `gh` if you install it) can authenticate.

### Env setup (`setup`)

`setup` is an install-style recipe for the **environment root filesystem** — clone-independent global and system packages the agent needs every session (CLIs, runtimes, browsers). Prefer it over installing the same tools from scratch on every prompt. Do **not** use it for long-lived servers or for repo-local `npm install` that depends on a cloned tree (run those under `/workspace/session` after clone).

Hosted env Images are built from `setup` **without** the shared filesystem mounted. After `setup` runs, Luno OS **clears `/workspace/agent`** so session create can attach the shared filesystem. That means:

* Install onto the root filesystem only (`/usr`, `/usr/local`, `$HOME`, Playwright’s default browser cache, etc.).
* **Do not** write under `/workspace/agent` in `setup` — those paths are deleted before the env Image is saved.
* **Do not** expect deployed files or skills under `/workspace/agent` to exist during env setup (the shared filesystem is not mounted yet). Put scripts you need during setup inline in the `setup` commands, or install tools to root-disk paths.

```ts theme={null}
sandbox: defineSandbox({
  mode: "vm",
  setup: [
    // CLIs (global / root-disk — not /workspace/agent)
    "npm i -g @posthog/cli",
    "curl -fsSL https://bun.sh/install | bash",
    // System packages
    "apt-get update && apt-get install -y jq ffmpeg",
    // Browsers / Playwright (default cache under $HOME — not /workspace/agent)
    "npx --yes playwright@1.49.0 install --with-deps chromium",
  ],
}),
```

Typical `setup` jobs:

| Goal | Example commands |
| - | - |
| Global Node CLIs | `npm i -g pnpm@10`, `npm i -g @posthog/cli` |
| Other package managers / runtimes | install Bun, uv, or a pinned Python toolchain |
| System packages | `apt-get install -y jq ripgrep ffmpeg` |
| Playwright / Chromium | `npx playwright install --with-deps chromium` (or a pinned Playwright version) |
| GitHub CLI | `… install gh` then use `gh` with injected `GH_TOKEN` |

Startup order on the **first prompt** of a session: create VM → inject env → git bootstrap → `setup` commands (fail-fast on non-zero exit) → agent tools. Remounts and later prompts in the same session skip `setup` (and skip git clone when the repo already exists).

On hosted VMs:

* Deploy and `luno agents snapshot <slug>` build an **org-scoped env Image** (root filesystem snapshot) from `setup` (shared across agents with the same setup fingerprint).
* Cold boots prefer that env Image and skip re-running `setup`; git clone still runs at session start into `/workspace/session`.
* If no env Image is ready yet, the first session runs `setup` inline (with the shared filesystem already mounted) and enqueues a background build.
* Deploy also reconciles `/workspace/agent` files and skills onto the shared filesystem in parallel with env Image builds, so first-prompt file prepare is usually a noop.

**What's already in the VM**

| Runtime | Image / snapshot | `git` | GitHub CLI (`gh`) |
| - | - | - | - |
| Local / microsandbox (`pnpm dev`) | OCI `node:26` (default) | Included | Not included — install via `setup` or in-session |
| Hosted cloud | Named runtime Image (+ optional org env Image / session Image) | Installed in the Image | Prefer via `setup` → env Image; else in-session |

Built-in `git.clone` uses the `git` binary plus a credential helper that reads `GH_TOKEN` / `GITHUB_TOKEN` — it does not require `gh`. If a managed OAuth provider redacts tokens, the dashboard offers a **Request access** flow (`/{org}/integrations?requestAccess={app}`).

In both modes the agent itself runs in the Luno OS 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

> **[Run agents](https://docs.os.meetluno.ai/learn/agents/run-agents.md)**
>
> Prompt agents from the CLI and inspect sessions.

> **[Test agents](https://docs.os.meetluno.ai/learn/agents/test-agents.md)**
>
> Add tests and local prompts before deploying agent changes.

> **[External channels](https://docs.os.meetluno.ai/learn/agents/external-channels.md)**
>
> Route Slack messages to an agent.

> **[Agent runs](https://docs.os.meetluno.ai/learn/logs/agent-runs.md)**
>
> Review conversation history, tool calls, traces, and errors.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.