The two streamline files: overview, theme, attachments, scanning, voice, archive and cancel, models, writing instructions
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
{
"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.
"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 anddirect; a confirm-gated tool still opens its card, a tap never skips it) orscan: true(the streamline must setscan). - **
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.*. Nevercontext.*,$or@. A path that doesn't resolve just means the rule doesn't hold, so write rules against the result shapesinspect-mcp.mjsprints. - Holes:
label,argsandfallbackholes are rule paths. Intextandinserta bare{{word}}is a slot for the employee;send: truetext has none. - Labels are a few words (≤ 40 characters once filled; longer chips are left out of the row).
- Precedence: a server's own
suggestionsdirective (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; warningsuggestions.label.modelSuggestions(the model proposing chips) is for personal streamlines only: in an organization's file it issuggestions.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
"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
"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
{
"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.