---
sidebar_position: 7
title: Designing agents
---

# Designing agents

Before you build an agent, learn the three patterns that make Zibby agents
powerful. Almost every good agent is some combination of them:

1. **One agent, many entry points** — a single deployed agent that behaves
   differently depending on *how* it was triggered (cron vs. a human vs. a
   webhook), selected by a code **router** node.
2. **Zero-LLM code nodes that read & write Stores** — deterministic work
   (fetch, ETL, aggregate, persist) runs as plain code, no model call, and can
   read/write a Store (SQLite / file / dataset) directly.
3. **Event-driven agents** — one agent subscribes to several events (PR opened,
   comment, `@mention`) and routes each to the right branch.

These are not exotic. The shipped **sentry-triage** and **engineering-insights**
agents use all three at once. Study them — they are the canonical reference for
"how an agent should be designed."

:::tip Design rule
**Don't reach for an LLM node when plain code will do.** A model call costs
tokens, adds latency, and is non-deterministic. Fetching data, transforming
JSON, running a query, writing to a Store, calling an API — all of that belongs
in a **code node**. Reserve LLM nodes for judgment: classify, summarize, decide.
:::

---

## Pattern 1 — one agent, many entry points

The trap beginners fall into is building *two* agents: a "collector" that runs on
a schedule and a "reporter" a human triggers. Don't. Build **one** agent whose
**first node is a code router** that inspects the trigger and sends the run down
the right branch. One bundle, one deployment, one set of env/Store bindings.

```js
import { WorkflowGraph } from '@zibby/agent-workflow';
import { z } from '@zibby/core';

// The router is PURE CODE — no model call. It reads the trigger input off state
// and returns the name of the next node.
function route(state) {
  // state.trigger / state.mode / state.action come from the trigger payload,
  // spread onto the initial state by the runner.
  return state?.trigger === 'fix' ? 'fix_intake' : 'fetch_issues';
}

const graph = new WorkflowGraph()
  .addNode('route', {
    _isCustomCode: true,
    description: 'Routes on the trigger: "fix" runs the single-issue auto-fix branch, anything else runs the scheduled triage branch.',
    outputSchema: z.object({}).passthrough(),
    execute: async (ctx) => ctx.state.getAll(), // router only decides an edge; it forwards state
  })
  // …branch nodes…
  .setEntryPoint('route')
  // A conditional edge: run `route`, then take the edge its return value names.
  .addConditionalEdges('route', route, {
    labels: { fix_intake: 'auto-fix one issue', fetch_issues: 'scheduled triage' },
  });
```

The **same agent** then gets three trigger sources, all landing on `route`:

| Trigger | Payload | Branch it takes |
|---|---|---|
| Cron (nightly) | `{ mode: 'collect' }` | collect → persist to Store |
| Human `zibby agent trigger` | `{ action: 'report' }` | read Store → generate → deliver |
| Webhook / Lark `@bot` | `{ trigger: 'fix', issueId }` | single-item fix branch |

> **Real reference:** `sentry-triage` routes `state.trigger === 'fix'` to an
> auto-fix branch and everything else (incl. an absent trigger — the nightly
> cron) to scheduled triage. `engineering-insights` routes `state.action`
> between `collect` (meter commits → SQLite) and `report` (aggregate → charts).

---

## Pattern 2 — code nodes read & write Stores (no LLM)

A **code node** (`_isCustomCode: true` + an `execute` function) is a first-class
citizen. It runs in the same container as the rest of the graph and has the same
environment — including any **Store** bound to the node. So a deterministic node
can fetch from a Store, transform, and write results back, entirely without a
model.

### How a node reaches a Store

When you bind a Store to a node (via `zibby_set_node_stores`, the deploy modal,
or the CLI), the runtime injects three env vars into that node's container:

| Env var | What it is |
|---|---|
| `ZIBBY_STORE__<name>` | the **storeId** of the Store you bound as `<name>` |
| `ZIBBY_ACCOUNT_API_URL` | the base URL of the datasets API |
| `PROJECT_API_TOKEN` | a project-scoped bearer token |

The node just `fetch`es the datasets API. No SDK required.

```js
// A pure-code node: read from a SQLite Store, write to a file Store. Zero LLM.
const BASE  = process.env.ZIBBY_ACCOUNT_API_URL;
const TOKEN = process.env.PROJECT_API_TOKEN;

async function ds(storeId, action, body) {
  const r = await fetch(`${BASE}/datasets/stores/${encodeURIComponent(storeId)}/${action}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${TOKEN}` },
    body: JSON.stringify(body),
  });
  if (!r.ok) throw new Error(`${action} ${r.status}: ${await r.text()}`);
  return r.json();
}

graph.addNode('build_report', {
  _isCustomCode: true,
  description: 'Reads metrics from a SQLite Store, writes the rendered report to a file Store. Pure code — no model call.',
  outputSchema: z.object({ topDevs: z.array(z.any()), wrote: z.boolean() }),
  execute: async (ctx) => {
    const state      = ctx.state.getAll();
    const metricsDb  = process.env.ZIBBY_STORE__metrics_db;    // a sqlite Store
    const reportFile = process.env.ZIBBY_STORE__report_files;  // a file Store

    // ── SQLite READ ── (readOnly:true → read-only guard)
    const q = await ds(metricsDb, 'sql', {
      sql: 'SELECT dev, added FROM commits WHERE added > ? ORDER BY added DESC LIMIT 10',
      params: [100],
      readOnly: true,
    });
    // q = { columns: ['dev','added'], rows: [['alice', 420], …] }

    // ── SQLite WRITE ── (bound params; a brand-new table is created STRICT)
    await ds(metricsDb, 'sql', {
      sql: 'INSERT INTO runs (ran_at, top_dev) VALUES (?, ?)',
      params: [state.ranAt, q.rows[0]?.[0] ?? null],
    });

    // ── file WRITE / READ ──
    await ds(reportFile, 'put', { path: 'report.html', content: state.html });
    const tpl = await ds(reportFile, 'get', { path: 'template.html' });

    return { topDevs: q.rows, wrote: true };
  },
});
```

### The Store data actions

| Store type | Actions | Notes |
|---|---|---|
| `sqlite` | `sql` `{ sql, params?, readOnly? }` | a real relational DB — tables, joins, UPDATEs. `readOnly: true` refuses any write. New tables are created **STRICT** so a column's declared type is enforced. |
| `file` | `put` / `get` / `list` / `delete` `{ path, content? }` | arbitrary blobs by relative path — good for whole-JSON inputs and rendered outputs. |
| `dataset` | `append` `{ record }` / `query` `{ select?, where?, … }` | append-only records + SQL-style aggregation — good for raw event streams you later `count`/`sum`/`group`. |

:::tip Choosing a Store type by data shape
- **Whole-blob** (a JSON file a script reads with `readFileSync`, a rendered
  report) → **file**.
- **Rows you dedupe / update / join** (git commits keyed by SHA) → **sqlite**.
- **Append-only events you aggregate later** (MRs, worklogs) → **dataset**.
- **A tiny cursor** ("where did I get to last time") → **kv-memory** (built into
  every agent, not a registry Store).
:::

### File-store size limit — and how to handle big files

A `file` store's per-file cap is **4 MiB of raw bytes** (the content travels
base64-inside-JSON through the API). Two ways past it:

- **A relational/append-only shape? Use `sqlite`/`dataset`, not `file`.** Those
  aren't whole-file uploads — they take rows/records, so the 4 MiB blob cap
  doesn't apply. This is the right move for raw event data (commits, worklogs).
- **A genuinely large blob** (a multi-MB `.jsonl` a script must read whole)?
  **gzip it, transparently.** Text compresses well; store the compressed bytes
  and decompress on read, so the generating script still sees the original file:

  ```js
  // write node: gzip + base64 → store as <name>.gz.b64
  import { gzipSync } from 'node:zlib';
  const packed = gzipSync(Buffer.from(rawText)).toString('base64'); // 5.6MB → ~0.8MB
  await ds(fileStore, 'put', { path: 'big.jsonl.gz.b64', content: packed });

  // read node: fetch → base64-decode → gunzip → original file on disk
  import { gunzipSync } from 'node:zlib';
  const got = await ds(fileStore, 'get', { path: 'big.jsonl.gz.b64' });
  const raw = gunzipSync(Buffer.from(got.content, 'base64')).toString('utf8');
  ```

  Keep the list of "which files are compressed" as data (one array), so adding a
  big file later is a one-line change — the read/write nodes stay generic.

:::caution Check every dependency's SIZE up front
When you list a store's inputs, don't glob one extension and assume — a single
oversized dependency (e.g. a 5.6 MB `.jsonl` hiding among small `.json`s) is what
trips the cap. Enumerate the real files + their bytes before wiring the store.
File paths accept Unicode (`报告.html` is fine); only `/ \ : ? # [ ] " < > | *`
and control characters are rejected.
:::

### Debugging a self-host run when logs look truncated

Self-host truncates long per-node step logs, so a node can report `success`
while a downstream step silently failed (you see all-green but empty output).
The robust pattern: **have each node persist its own full result JSON into an
output store** (e.g. `diag/<node>.json`) — best-effort, never throwing, never
changing the node's return value. Then read the diagnostics back with
`zibby_store_peek` to see exactly what each node saw (staged files + sizes,
stderr, the real error). This turns a silent all-green failure into a precise
root cause.

---

## Pattern 3 — event-driven agents

A single agent can subscribe to **several** events and handle each differently.
The shipped **github-code-review** / **gitlab-code-review** agents are one agent
that reacts to *PR opened*, *new commits pushed*, *a comment reply*, and an
*`@mention`* — the entry router branches on which event fired. The owner even
chooses **which** events trigger a review (cost control), because the whole thing
is just an event-subscription subset feeding the same router.

The takeaway: **you rarely need a second agent for a new trigger.** Add an event
to the subscription, add a branch, extend the router.

---

## Putting it together — a design checklist

When you design a new agent, ask:

1. **How many ways will this be triggered?** More than one → **Pattern 1**: a
   code `route` node at the entry, one agent, branches per scenario.
2. **What here is deterministic?** Fetch / transform / query / persist → **code
   nodes** (Pattern 2), not LLM nodes. Bind the Stores those nodes need.
3. **What actually needs judgment?** Only *those* steps get an LLM node
   (`agent: 'claude' | 'codex' | …` + a `prompt` + an `outputSchema`).
4. **Where does the data live?** Pick a Store type per data shape (blob → file,
   relational → sqlite, append-only → dataset; cursor → kv-memory).
5. **Is the split write-side vs. read-side?** Keep it **one agent** — a nightly
   `collect` branch that writes the Store and an on-demand `report` branch that
   reads it, selected by the router. Not two agents.

Then read the source of **sentry-triage** and **engineering-insights** and map
each pattern onto what you see. That is the fastest way to learn what Zibby can
do.
