# A streamline: `agents/<kind>.json` + `private/agents/<kind>.json`

An organization's streamline is two files with the same id: what devices see (public) and what only the relay
reads (private). A personal streamline is one flat file instead (personal-streamline.md).

## Contents
- The device file, agents/<kind>.json
- The overview (inspector)
- Suggestions
- Attachments and scanning
- Voice
- The private file, private/agents/<kind>.json
- Models
- Writing instructions

## The device file, `agents/<kind>.json`

```json
{
  "id": "receiving",
  "name": "Receiving",
  "description": "Receive a purchase order against what arrived.",
  "icon": "shippingbox.fill",
  "color": { "light": "#4C4FC4", "dark": "#9A9CF5" },
  "roles": ["receiving", "store_manager"],
  "newChatLabel": "Receive a PO",
  "chatTitle": { "from": "overview", "text": "{{ $.po.number }} · {{ $.po.vendor }}" },
  "welcome": {
    "messages": ["This chat is one purchase order. Give me the PO number or a vendor, then tell me what's in the box."],
    "starters": [ { "label": "Open a PO", "text": "Open PO ", "send": false },
                  { "label": "Today's deliveries", "text": "Which POs are due today?", "send": true } ]
  },
  "composer": {
    "placeholder": "PO, line, quantity, damage…",
    "quickPhrases": [ { "group": "Lines", "items": [
      { "label": "Short on line…", "text": "Line {{line}} is short by {{qty}}" },
      { "label": "Close the PO", "text": "Close the PO", "send": true } ] } ]
  },
  "overview": { "title": "Purchase order", "source": { "tool": "po_get", "args": { "poId": "{{ chat.poId }}" } },
                "requires": ["chat.poId"], "template": "receiving.overview", "compactTemplate": "receiving.overview.compact",
                "refreshOn": ["po_*", "receive_*", "receiving_*"], "empty": "No PO open yet." },
  "renderers": { "po_search": "receiving.po.list", "receive_line": "receiving.line" },
  "activity": { "po_search": "Finding the PO", "receiving_close": "Closing the PO" },
  "completion": { "tools": ["receiving_close"], "banner": "{{ $.poNumber }} received", "next": "Receive another" },
  "forms": [], "views": []
}
```

| Field | Req | Notes |
|---|---|---|
| `id` | yes | `[a-z0-9_]{2,32}`, equals the file name and an entry of `tenant.json` `kinds`. |
| `name`, `description` | name | Sidebar header; one line in the New menu. |
| `icon` | yes | An SF Symbol name (`bag.fill`, `checkmark.seal.fill`, `shippingbox.fill`, `doc.text.fill`…). |
| `logo` | no | A square PNG or JPEG (64–1024 px, ≤ 256 KB): `assets/<name>.png` in an organization's folder, or `data:image/png;base64,…` in a `*.streamline.json`. Drawn in full color where the icon would be; the icon stays the fallback. Use another company's logo only where its brand rules allow it. |
| `color` | yes | `{light, dark}` hex; outgoing bubbles use it with white text, so keep contrast ≥ 4.5:1. With `theme`, set it to the theme's resolved accent (older app builds draw `color`). |
| `theme` | no | What the streamline wears: a preset id (`base`, the default; `quiet`, `harbour`, `refined-calm`, `amber`) or one of the account's `themes[].id`, whole (it must have light and dark sides); or `{ "base"?: id, "accent"?: {light, dark} }` to keep the account's theme with another accent. Absent: the account's theme (`tenant.json → theme`) with `color` as the accent. Colors only, no CSS. |
| `roles[]` | yes | Role ids from `tenant.json` who see this streamline. The API authorizes again. |
| `network` | no | `{ "restricted": true, "message": "Works only on the store network." }`: only the fact; the rule is private. Must agree with the private side. |
| `newChatLabel` | no | The + button's label ("New sale", "Receive a PO"). |
| `chatTitle` | no | `from`: `overview` or `firstMessage`; `text`: interpolated (templates.md). `rules`: `[{ "tool": "customer_get", "text": "{{ $.customer.number }} · {{ $.customer.name }}" }]`: a successful result of that tool titles the chat, `$` = its structured result; the latest match wins, an empty or unresolved title keeps the last one, a renamed chat never changes, the agent never writes titles. Each rule's tool must be allowlisted. Without anything that resolves, the tab says `newChatLabel` and then the first message. |
| `archive` | no | `whenDone` (default: a chat is archived only after its job completed or was canceled), `anytime`, `never`. |
| `cancel` | no | `{ "tool", "label"?, "args"? }`: the step that cancels a job mid-way; the tool must be allowlisted and `direct`, and the model never sees it (only the employee runs it, from Cancel Job…). Without it the app can't close a `whenDone` chat early. |
| `welcome.messages[]` | no | Local first bubbles; never sent to the model. Interpolates `user.*`, `tenant.*`. Say what one stream is. |
| `welcome.starters[]` | no | ≤ 4 chips: `label`, `text`, `send` (true sends at once; false fills the composer), `scan: true` (opens the scanner instead; needs `scan`). |
| `composer.placeholder`, `composer.quickPhrases[]` | no | Groups of `{label, text, send}`; `{{slot}}` markers become tab-stops; `send: true` only without slots. An item may carry a `tool` action instead of `text` (templates.md, actions). |
| `composer.suggestions[]` | no | Chips that follow the job's state after the first message (below, *Suggestions*). ≤ 12 rules. |
| `overview` | no | The live inspector (below). |
| `renderers` | no | tool → template id, for results that don't name a template themselves. Every tool must be allowlisted. |
| `activity` | no | tool → caption while it runs. |
| `completion` | no | `tools[]` that end the job, `banner` (interpolated against that tool's result), `next` (label of the new-stream button). |
| `forms[]`, `views[]` | no | Form/view ids this streamline may open; any other id is refused. |
| `attachments`, `scan`, `voice` | no | Below. |
| `imageHosts[]` | no | Extra hosts this streamline's templates may load images from, beside `tenant.json` `imageHosts`. |

## The overview (inspector)

One object for the whole job, fetched by calling one **read-only** tool directly (no model), drawn by one template.
`source.tool` must be allowlisted with `readOnly: true`. `source.args` interpolates `chat.*` (state set by tools via
`chat.set`) and `user.*`. `requires[]` waits for paths before fetching. `refreshOn[]` globs re-fetch after a
matching successful call. `poll: { "seconds": 15, "when": { "path": "$.offer.status", "eq": "pending" } }` re-fetches
while a condition holds (≥ 10 s). `compactTemplate` is the one-line strip on iPhone.

## Suggestions

After the first message, the chip row above the composer can show the next sensible step for the job's **current
state**, from data the device already has. No model is called and nothing is asked of the MCP server, so this works
for servers you don't control. Each rule is a chip plus a `when`; the first three that hold, in file order, show.

```json
"composer": {
  "suggestions": [
    { "when": { "all": [ "overview.items", { "path": "overview.balance", "gt": 0 } ] },
      "label": "Take payment", "text": "Take a card payment for {{ overview.balance | money }}", "send": true },
    { "when": { "path": "chat.offerStatus", "eq": "pending" },
      "label": "Check approval", "text": "Has the offer been approved?", "send": true },
    { "when": { "not": "overview.customer" }, "label": "Find a customer", "insert": "Find customer by phone {{phone}}" },
    { "when": { "path": "last.tool", "eq": "po_get" }, "label": "Scan the next box", "scan": true },
    { "when": "overview.balance", "label": "Cash {{ overview.balance | money }}", "tool": "payment_add_cash",
      "args": { "cartId": "{{ chat.cartId }}", "amount": "{{ overview.balance }}" }, "fallback": "Cash for the balance" }
  ]
}
```

- **One action per chip:** `text` (+ `send`), `insert` (slots selected), `tool` (+ `args`, `fallback`: the fast path,
  the tool must be allowlisted and `direct`; a confirm-gated tool still opens its card, a tap never skips it) or
  `scan: true` (the streamline must set `scan`).
- **`when`** is the templates' condition language (`eq ne in gt lt not all any`, a bare path = truthy) over the rule
  context: `overview.*` (only with an overview), `chat.*` (chat state), `last.tool` / `last.result.*` (the latest
  successful tool call, by anyone: use it when there is no overview), `stream.messages` (the employee's message count),
  `stream.status` (`open` / `waiting` / `done`), `user.*`, `tenant.*`. Never `context.*`, `$` or `@`. A path that
  doesn't resolve just means the rule doesn't hold, so write rules against the result shapes `inspect-mcp.mjs` prints.
- **Holes:** `label`, `args` and `fallback` holes are rule paths. In `text` and `insert` a bare `{{word}}` is a slot for
  the employee; `send: true` text has none.
- **Labels** are a few words (≤ 40 characters once filled; longer chips are left out of the row).
- **Precedence:** a server's own `suggestions` directive (if it sends one) replaces the rules until the next send;
  before the first message the starters own the row; chips hide while the agent is working, while a confirm card or
  sealed form is open, and once the job is done. Rule chips stay while their state holds.
- **Checker codes:** `suggestions.root`, `suggestions.context`, `suggestions.noOverview`, `suggestions.notDirect`,
  `suggestions.scan`, `phrase.slots`; warning `suggestions.label`. `modelSuggestions` (the model proposing chips) is
  for personal streamlines only: in an organization's file it is `suggestions.modelOrg`.
- Good rules name the step a person at the desk would take next for that state (balance due → take payment; offer
  pending → check approval). Don't encode business rules (law 3): the API still decides.

## Attachments and scanning

```json
"attachments": { "allowed": true, "types": ["public.image", "com.adobe.pdf"], "camera": true, "maxCount": 6, "maxBytes": 8388608, "maxPages": 10, "toModel": true },
"scan": { "types": ["code128", "qr"], "modes": ["single", "continuous"], "defaultMode": "single", "pattern": "^[A-Z]-\\d{5}$", "action": { "tool": "cart_add_item", "args": { "cartId": "{{ chat.cartId }}", "sku": "{{ scan.value }}" } } }
```

`types` come from one list (configcheck `attachments.type`): images (`public.image`, or `public.jpeg`, `public.png`,
`public.heic`, `com.compuserve.gif`, `org.webmproject.webp`, `public.tiff`), `com.adobe.pdf`, and text
(`public.plain-text`, `public.comma-separated-values-text`, `public.tab-separated-values-text`, `public.json`,
`net.daringfireball.markdown`, `public.xml`, `public.yaml`). No Word or Excel: people export to PDF or CSV. Limits:
`maxCount` (per message, default 6), `maxBytes` (per file, default 8 MB, never above the account's
`policy.attachmentsMaxBytes`), `maxPages` (per PDF, default 20), `maxTextChars` (default 100000), `imageMaxEdge`
(512–2576, default 1568), `chatMaxBytes` (the whole stream, default 16 MB, at most 24 MB for an organization). Images
reach the model downscaled with no metadata, PDFs and text files as documents; the files go through manolab's upload
store (encrypted, `policy.uploadRetentionDays`, default 7) and never into a transcript.

`toModel: false` gives the model only an upload reference a tool can accept (the app hands the tool a 15-minute
link). Never use attachments for sensitive documents (a driver's license): that is a sealed form's `image` field;
when a streamline has both, say which is which in its private instructions (mention attachments and the secure
form), or configcheck warns `attachments.sealedOverlap`.

`scan` adds the scan button (config-schema §4.2a). `types`: groups `barcode` / `qr` or symbology ids (`ean13`,
`code128`, `datamatrix`…); never `pdf417` (licenses: sealed forms only). Without `action` a scan waits in the composer
as a tagged chip and the model gets `<scan type=… symbology=… source=…>value</scan>`; with `action.tool` it is the fast
path: that tool must be `direct`, and not `confirm` when `modes` allows `continuous` or `sweep`; `action.insert` puts
text in the composer and `action.send` sends it at once, the scan riding along either way. `modes` lists what the
scanner offers (`single`: the code under the laser line, then close; `continuous`: one code at a time, stays open;
`sweep`: every code in view while the person moves across a sheet or shelf, reviewed, then sent as one batch: one
`<scans count=… mode="sweep">` block, or one fast-path call per code), `defaultMode` what a tap opens (default
`single`; it must be in `modes`). The old `mode` (`single`/`continuous`/`both`) is still read. `pattern` is a UX filter
only; your API validates the value. `keyboard` tunes hardware-scanner detection (`false` turns it off).

## Voice

```json
"voice": { "startInVoiceMode": true, "engine": "auto", "hints": ["Harbor Point", "packing slip"] }
```

All optional; absent = the mic and *Speak replies* are on offer with the defaults. `input: false` / `output: false`
switch the mic / Speak replies off for this streamline (`tenant.json` `policy.voice: false` does it account-wide);
`startInVoiceMode` starts new streams with replies spoken (the employee can still turn it off); `engine: "dictation"`
uses Apple's DictationTranscriber, which takes up to 100 `hints` (product and place words). Speech becomes text on the
device; no audio ever leaves it, and the model sees only the text. A card is announced by its template's
`speech.summary` (templates); sealed cards, receipts and sealed views are only ever "A secure form is on screen", and a
confirm card is always a tap. Turn voice on for jobs where hands are full (receiving, stock counts), not as the default.

## The private file, `private/agents/<kind>.json`

```json
{
  "id": "receiving",
  "instructions": "You are the receiving desk at Meridian Jewelers. You help one person receive one purchase order in this chat.\n\n- Find the PO with po_search and open it with po_get.\n- Record what arrived with receive_line; short or damaged units with discrepancy_report.\n- receiving_close waits for the person's tap on a confirmation card.\n- Keep replies short. The PO panel shows the lines.\n- Today is {{ now | date:'long' }}. The receiver is {{ user.name }}.",
  "model": { "provider": "anthropic", "id": "claude-haiku-5-5", "effort": "low", "maxTokens": 2048, "credentialRef": "anthropic-main" },
  "fallbackModel": { "provider": "anthropic", "id": "claude-sonnet-5-5", "effort": "low", "maxTokens": 2048, "credentialRef": "anthropic-main", "when": ["overloaded", "refusal"] },
  "mcp": [ {
    "id": "pos", "url": "https://api.meridian.example/mcp", "auth": "oauth", "connection": "pos", "resource": "https://api.meridian.example/mcp",
    "tools": [
      { "name": "po_search", "readOnly": true },
      { "name": "po_get", "readOnly": true, "direct": true },
      { "name": "receive_line" },
      { "name": "discrepancy_report" },
      { "name": "receiving_close", "confirm": { "template": "confirm.receiving", "preview": "po_get", "button": "Close PO" } }
    ] } ],
  "network": { "mode": "user" },
  "limits": { "maxToolCallsPerTurn": 12, "maxTurnsPerChat": 120, "tokensPerUserPerDay": 300000, "costPerUserPerDayUSD": 2 },
  "transcript": { "keepToolResults": 6 }
}
```

| Field | Req | Notes |
|---|---|---|
| `instructions` | yes | The system prompt (below). Interpolates `user.*`, `tenant.*`, `now`. |
| `model`, `fallbackModel` | model | Below. `fallbackModel.when[]`: `overloaded`, `refusal`, `timeout`. |
| `mcp[]` | yes | One or more servers: `id`, `url` (Streamable HTTP), `auth` (`oauth` \| `password` \| `api-key` \| `none`), `connection` (default: the entry's id; access-and-networks.md), `resource` (the OAuth audience), `reauthOn` (`oauth` only: business error codes that mean the session ended, e.g. `["NOT_CUSTOMER_SESSION"]`; the app signs in again and retries, and the model never sees that result), `tools[]` the **allowlist** (tool-policy.md). A tool not listed is never shown to the model. |
| `network` | no | This streamline's network rule; overrides the account default (access-and-networks.md). |
| `limits` | no | `maxToolCallsPerTurn` (10), `maxModelCallsPerTurn` (8, at most 40), `maxTurnsPerChat` (120), `tokensPerUserPerDay`, `costPerUserPerDayUSD`. |
| `briefs` | no | Brief ids composed before `instructions`, in order (briefs.md). |
| `transcript.keepToolResults` | no | Older tool results trimmed from what the model sees (default 6). |
| `sharing.link` | no | `true` lets owners and admins share this streamline by link (Discover; listings.md). Off by default. A shared copy runs on the recipient's own key, without roles, network rules, budgets or the model key. |

## Models

| Field | Notes |
|---|---|
| `provider` | `anthropic`, `openai`, `openai-compatible` (with `baseURL`, e.g. Groq or a self-hosted server), `demo`. |
| `id` | The provider's model id. Anthropic, October 2026: `claude-haiku-5-5` (desks: cheapest, fast; the default), `claude-sonnet-5-5` (fallback, or desks needing more judgment), `claude-opus-5-5` (rarely at a desk). |
| `effort` | `none` \| `low` \| `medium` \| `high` \| `max`. Desks usually `low`. |
| `maxTokens` | Output ceiling per call (desks: 2048 is plenty). |
| `credentialRef` | **Names** a key the owner stores in the account (e.g. `anthropic-main`). Never the key. The organization's own key, so the bill and data terms are theirs. |
| `inputPricePerMTok`, `outputPricePerMTok` | Optional USD prices for models the relay has no table for. |

## Writing instructions

- Persona and order of work for **one job**: "You help one sales associate complete one sale in this chat."
- Name the tools and when to use them; say which ones wait for a tap.
- **No business rules as rules** ("never discount over 10%"): the API enforces those. You may tell the model what
  the API will do so it can explain it.
- Never ask for, repeat or accept secrets in chat; name the sealed form or hosted page tool instead.
- Short replies: the overview shows the details.
- Stable text first (it is cached); per-stream facts arrive in the conversation, not here.
- Under ~1,500 words. No keys, URLs with tokens, or personal data.
- Guidance every streamline shares (voice, words, never-do's) goes in a **brief** the streamline lists in `briefs`
  (briefs.md): written once, composed before these instructions.
