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

# Build an Environment From Scratch

> A complete environment, from an empty template to a passing run

This walks through building a small support-desk environment from `blank`. Every command and every output below is from an actual session — you can follow along and get the same results.

The order matters. Each step depends on the one before it, and doing them out of sequence means editing generated files by hand.

## 1. Scaffold

```bash theme={null}
npx @burn0/silo init helpdesk --template blank
```

```
Created environment "helpdesk" from Blank at ./.silo/environments/helpdesk
```

Blank gives you the contract wired up and nothing in it:

```
.silo/environments/helpdesk/
├── silo.environment.json
├── index.ts               the three exports
├── environment.ts         returns {}
├── state.ts               export type State = Record<string, unknown>
├── data/
├── tasks/
├── tools/index.ts         an empty registry
└── verifiers/index.ts     an empty registry
```

## 2. Add data

Facts only — no computed values.

```json tickets.json theme={null}
[
  { "id": "TKT-001", "subject": "Card declined at checkout", "status": "open", "priority": "high", "customerId": "CUS-001", "assigneeId": null, "openedDate": "2026-03-02" },
  { "id": "TKT-002", "subject": "Cannot reset password", "status": "open", "priority": "normal", "customerId": "CUS-002", "assigneeId": "AGT-001", "openedDate": "2026-03-11" },
  { "id": "TKT-003", "subject": "Duplicate invoice received", "status": "resolved", "priority": "normal", "customerId": "CUS-001", "assigneeId": "AGT-002", "openedDate": "2026-02-18" },
  { "id": "TKT-004", "subject": "Export fails on large reports", "status": "open", "priority": "urgent", "customerId": "CUS-003", "assigneeId": null, "openedDate": "2026-01-29" }
]
```

```json agents.json theme={null}
[
  { "id": "AGT-001", "name": "Rosa Iqbal", "available": true },
  { "id": "AGT-002", "name": "Kenji Adeyemi", "available": false }
]
```

```bash theme={null}
npx @burn0/silo data add tickets --env helpdesk --file ./tickets.json
npx @burn0/silo data add agents  --env helpdesk --file ./agents.json
```

```
Created data/tickets.json
Created data/agents.json
```

Notice the shape of this seed: two tickets are unassigned, and only one of the two agents is available. That is deliberate — it gives a task a correct answer and a wrong-but-plausible one.

## 3. Write `state.ts` before anything else

This is the step people skip, and it costs the most. Scaffolded tools and verifiers import `State` and are generated against it, so writing state first means the stubs come out correct.

```typescript state.ts theme={null}
export type TicketStatus = "open" | "resolved" | "closed";
export type Priority = "low" | "normal" | "high" | "urgent";

export type Ticket = {
  id: string;
  subject: string;
  status: TicketStatus;
  priority: Priority;
  customerId: string;
  assigneeId: string | null;
  openedDate: string;
};

export type Agent = { id: string; name: string; available: boolean };

export type State = {
  now: string;
  tickets: Record<string, Ticket>;
  agents: Record<string, Agent>;
};

export function indexById<T extends { id: string }>(rows: T[]): Record<string, T> {
  return Object.fromEntries(rows.map((row) => [row.id, row]));
}

export function daysOpen(state: State, ticket: Ticket): number {
  const start = Date.parse(`${ticket.openedDate}T00:00:00.000Z`);
  const end = Date.parse(`${state.now}T00:00:00.000Z`);

  return Math.round((end - start) / 86_400_000);
}

/** An open ticket nobody owns. These are what a triage task is about. */
export function isUnassigned(ticket: Ticket): boolean {
  return ticket.status === "open" && ticket.assigneeId === null;
}
```

Two things worth copying: `now` is in state because the domain has a clock, and `isUnassigned` is a helper because both a tool and a verifier will need that definition.

## 4. Build the world

```typescript environment.ts theme={null}
import { type Agent, type State, type Ticket, indexById } from "./state.js";

import agentsData from "./data/agents.json" with { type: "json" };
import ticketsData from "./data/tickets.json" with { type: "json" };

/** Fixed so that "days open" is reproducible. */
export const SIMULATION_NOW = "2026-03-16";

export function createState(): State {
  return structuredClone({
    now: SIMULATION_NOW,
    tickets: indexById(ticketsData as Ticket[]),
    agents: indexById(agentsData as Agent[]),
  });
}
```

<Warning>
  `structuredClone` is not optional. Node caches imported JSON for the process lifetime, so without it the second rollout starts from whatever the first one left behind — which presents as flaky grading, not an obvious bug.
</Warning>

## 5. Scaffold the tools

```bash theme={null}
npx @burn0/silo tool add list_tickets --env helpdesk \
  --description "List tickets, optionally filtered by status or whether they are unassigned."
npx @burn0/silo tool add list_agents --env helpdesk \
  --description "List support agents and whether each is currently available to take work."
npx @burn0/silo tool add assign_ticket --env helpdesk \
  --description "Assign an open ticket to an available agent."
```

```
Created tools/list-tickets.ts
Registered listTickets in tools/index.ts
Next: implement run() in tools/list-tickets.ts (look for TODO).
```

Three tools for two collections is the minimum here: the agent must be able to see the queue, see who can take work, and act. Anything it cannot see through a tool, it cannot know.

## 6. Implement them

The generated stub throws on purpose. Replace the `TODO`:

```typescript tools/assign-ticket.ts theme={null}
export const assignTicket = defineTool<State>({
  name: "assign_ticket",
  description:
    "Assign an open ticket to an available agent. Refuses if the ticket is not open or the agent is unavailable.",
  inputSchema: {
    type: "object",
    properties: {
      ticketId: { type: "string", description: "Ticket ID, e.g. 'TKT-001'." },
      agentId: { type: "string", description: "Agent ID, e.g. 'AGT-001'." },
    },
    required: ["ticketId", "agentId"],
    additionalProperties: false,
  },
  run(state, input) {
    const ticket = state.tickets[String(input["ticketId"] ?? "")];
    if (!ticket) throw toolError("not_found", `Ticket was not found.`);

    const agent = state.agents[String(input["agentId"] ?? "")];
    if (!agent) throw toolError("not_found", `Agent was not found.`);

    if (ticket.status !== "open") {
      throw toolError("invalid_state", `Ticket ${ticket.id} is ${ticket.status}, not open.`);
    }

    if (!agent.available) {
      throw toolError("not_allowed", `Agent ${agent.name} is not available to take work.`);
    }

    ticket.assigneeId = agent.id;

    return { assigned: { ticketId: ticket.id, agentId: agent.id, agentName: agent.name } };
  },
});
```

The business rules live **in the tool**. Because `assign_ticket` refuses an unavailable agent, "assign these to someone who can take them" becomes a real task rather than a formality.

## 7. Add a task

```bash theme={null}
npx @burn0/silo task add --env helpdesk --id TASK-001 \
  --title "Triage the unassigned queue" \
  --instruction "Nobody is looking at the unassigned tickets. Assign every open ticket that has no owner to an agent who is actually available to take it." \
  --verifier VER-001 --difficulty easy
```

```
Created tasks/TASK-001.json
```

The instruction states the outcome and nothing else. It does not name the tools, say how many tickets there are, or mention that one agent is unavailable — discovering that is the work.

## 8. Scaffold and write the verifier

```bash theme={null}
npx @burn0/silo verifier add VER-001 --env helpdesk --task TASK-001 \
  --name "Unassigned tickets triaged to available agents"
```

```
Created verifiers/VER-001.ts
Registered ver001 in verifiers/index.ts
Next: replace the failing TODO check in verifiers/VER-001.ts.
```

```typescript verifiers/VER-001.ts theme={null}
export const ver001 = defineVerifier<State>({
  id: "VER-001",
  taskId: "TASK-001",
  name: "Unassigned tickets triaged to available agents",
  check(final, initial) {
    const needed = Object.values(initial.tickets).filter(isUnassigned);

    const assigned = needed.filter((ticket) => final.tickets[ticket.id]?.assigneeId !== null);

    const toAvailableAgents = needed.every((ticket) => {
      const assigneeId = final.tickets[ticket.id]?.assigneeId;

      return assigneeId === null || initial.agents[assigneeId]?.available === true;
    });

    return [
      check(
        "Every unassigned open ticket now has an owner",
        needed.length > 0 && assigned.length === needed.length,
        `${assigned.length} of ${needed.length} assigned`,
      ),
      check(
        "Work only went to agents who were available",
        toAvailableAgents,
        "no ticket assigned to an unavailable agent",
      ),
      optional(
        "Tickets that already had an owner were left alone",
        untouched,
        "existing assignments preserved",
      ),
    ];
  },
});
```

The set of tickets that needed an owner is derived from `initial`, not listed here. Add another unassigned ticket to the seed data and this check strengthens automatically.

## 9. Validate — this is the stopping condition

```bash theme={null}
npx @burn0/silo env validate --env helpdesk
```

```
OK	helpdesk	data=2 tasks=1 tools=3 verifiers=1
```

Do not skip to running. Validation compiles the whole environment, including the tools this task never calls. A run proves one path works; validation proves the environment is sound.

The counts are a second check: three tools means all three registered. If you wrote a tool and the number did not move, it is not in the barrel.

## 10. Run it

```typescript helpdesk.agent.ts theme={null}
export default async function agent({ callTool }: AgentInput) {
  const queue = await callTool("list_tickets", { unassignedOnly: true });
  const staff = await callTool("list_agents", { availableOnly: true });

  const tickets = (queue.output as { results: Array<{ id: string }> }).results;
  const agents = (staff.output as { results: Array<{ id: string }> }).results;

  const assigned: string[] = [];

  for (const [index, ticket] of tickets.entries()) {
    const agent = agents[index % agents.length]!;
    const result = await callTool("assign_ticket", { ticketId: ticket.id, agentId: agent.id });

    if (!result.isError) assigned.push(`${ticket.id} → ${agent.id}`);
  }

  return { output: `Assigned ${assigned.length} tickets: ${assigned.join(", ")}.` };
}
```

```bash theme={null}
npx @burn0/silo run --env helpdesk --task TASK-001 --agent ./helpdesk.agent.ts
```

```
  Silo Run

  Task          TASK-001 — Triage the unassigned queue
  Result        PASS
  Reward        1.00

  Tool calls    4
  Successful    4
  Errors        0

  Checks        3 / 3
  Required      2 / 2
```

## 11. Prove the verifier can fail

A gate that cannot fail is worse than no gate. Point a different agent at the same task — one that does not do the work:

```
  Result        FAIL
  Reward        0.67
  Required      1 / 2
  ✗ Every unassigned open ticket now has an owner
```

Now you know the check discriminates. Run this once for every verifier you write.

## What to do next

* Add a second task that produces an **answer** rather than a change — "how many tickets have been open more than 30 days?" — and derive the expected figure in the verifier using `daysOpen`.
* Add the awkward rows: a ticket assigned to an agent who has since become unavailable.
* Record a passing run's `result.json` as a baseline, so a change in behaviour shows up as a diff.
