# Toast — Builder Docs (full text) > The complete Toast developer guide for authoring kits, tools, flours, and doughs on Toast. > Source: https://postlab.ai/en/docs · Index: https://postlab.ai/llms.txt --- # Toast Builder Docs > Build automation units (doughs) and kits on Toast. This guide takes you from baking your first tiny kit to scheduling and publishing connected-app automations. ## What is Toast Toast turns "assembling an AI agent by hand every time" into **reusing what you built as a part and stacking it upward**. The part is called a `dough`, and a dough becomes an ingredient inside the next dough. An "AI agent" isn't one thing. It's a **stack** of model + prompt + tools + loop. Other tools make you wire that stack from scratch per automation. Toast treats an assembled unit as **another part** and runs it one level up. The more you stack, the easier the next build gets. For the bigger picture, see the product page. This page goes straight to **building**. ## Who this is for & your permissions This guide is for building kits, tools, and doughs on Toast by hand. It assumes you're comfortable with code and YAML. To build without code, chat with OVEN in the app. ### Your permission boundary Toast authors fall into three tiers. | tier | Who | Channel | tool/kit authoring | Signing | |------|-----|---------|--------------------|---------| | `user` | Non-developers | OVEN (chat) only | ✗ | ✗ | | **`third-party`** | **External developers** | **OVEN + kit SDK / hand YAML** | **✓** | ✗ | | `official` | postlab | Same + signing | ✓ | ✓ | The tiers are cumulative (user ⊂ third-party ⊂ official). You get everything a non-developer can do, plus tool/kit authoring. The one thing you cannot do is official signing. What you can build: a **kit** (the distribution unit), a **tool** (a Python function), a **tool flour** (YAML wrapping a tool), an **agent flour** (YAML where an LLM reasons over data), and a **composition** (flours/doughs wired together). `user` cannot author tools and uses only agent flours; you are not under that constraint. This guide centers on your first-class task: **kit authoring**. > **WARNING:** Publishing covers **only a dough closure** (= that one dough + every flour/dough it depends on). Putting a kit itself into the catalog is **not implemented in v1**. ## Two ways to author You can produce the same artifacts (kits, doughs) two ways. They complement each other. - dough-creator plugin — generate from natural language → verify → deploy. Fastest (v1 is auth-less kits only) - Hand-authoring — write it yourself (the guide below). Full control, OAuth, understanding ## Hand-authoring path If you author by hand, follow in order: - Quickstart — your first tiny kit end-to-end (Hello World) - Concepts — core concepts - Build — authoring tool/flour/agent/composition/kit + auth kits + the connected-app example - Ship — install, schedule, HITL, operational limits. See Publish and the example gallery too. - Reference — full field tables, glaze, ground truth --- # Hello World > Build and bake a kit that fetches a weather forecast by city. Five files, install, bake. Flow: `tools.py` (function) → `dough.yaml` (wrap as a flour) → `kit.yaml` (bundle) → install → bake. ## Kit layout A kit is these files. Create ①–⑥ below in order. ```text my_weather/ ├── kit.yaml # 매니페스트 — kit의 신원·인증 ├── tools.py # Python tool 코드 ├── types.py # model: 이 가리키는 Pydantic 모델 (kit 레벨) └── get_forecast/ # flour 디렉터리 (tool 1개당 1개) ├── dough.yaml # action + 입출력 스키마 └── box.yaml # 표시 라벨 (i18n) ``` ## ① tools.py A plain async function that calls an external API. Its return type is the output contract. ```python # my_weather/tools.py from __future__ import annotations import httpx from .types import Forecast _GEOCODE = "https://geocoding-api.open-meteo.com/v1/search" _FORECAST = "https://api.open-meteo.com/v1/forecast" async def get_forecast(city: str = "Seoul", days: int = 3) -> Forecast: """도시 이름으로 일별 날씨 예보를 가져온다 (Open-Meteo, 무인증).""" async with httpx.AsyncClient(timeout=20.0) as client: geo = (await client.get(_GEOCODE, params={"name": city, "count": 1})).json() if not geo.get("results"): raise ValueError(f"unknown city: {city!r}") loc = geo["results"][0] fc = (await client.get(_FORECAST, params={ "latitude": loc["latitude"], "longitude": loc["longitude"], "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum", "forecast_days": int(days), "timezone": "auto", })).json() daily = fc["daily"] return Forecast( city=loc["name"], dates=daily["time"], high=daily["temperature_2m_max"], low=daily["temperature_2m_min"], precip=daily["precipitation_sum"], ) ``` ## ② types.py Define the type the tool returns. `model:` points at this module. ```python # my_weather/types.py — 순수 Pydantic 모듈. app.* 는 절대 import 하지 않는다. from __future__ import annotations from pydantic import BaseModel class Forecast(BaseModel): city: str dates: list[str] # ISO 날짜 high: list[float] low: list[float] precip: list[float] class UmbrellaAdvice(BaseModel): # 핵심 개념의 agent flour가 사용 umbrella: bool outfit: str summary: str ``` ## ③ get_forecast/dough.yaml Wrap the tool in a flour with an input/output schema. `action.tool` binds the function above. ```yaml id: weather.get_forecast # ← id가 곧 경로 (점 = 폴더 계층) version: 0.1.0 icon: cloud source: kit verb: get object: forecast inputs: city: { type: string, required: true, default: Seoul } days: { type: number, required: false, default: 3 } outputs: forecast: type: object model: weather.types:Forecast # ← object/list 출력은 model: (또는 schema:) 필수 display: raw action: tool: weather.get_forecast # tools.py:get_forecast 바인딩 with: city: ${inputs.city} # ${...} = 참조 문법 days: ${inputs.days} to: forecast # 결과를 'forecast' 출력에 저장 return: forecast: ${forecast} # ← 외부로 노출 ``` ## ④ get_forecast/box.yaml Display labels — a name for every input and output. ```yaml en: name: "Get Weather Forecast" about: Daily forecast for a city from Open-Meteo inputs: city: { name: City, description: "City name, e.g. Seoul." } days: { name: Days, description: "How many days to forecast." } outputs: forecast: { name: Forecast, description: "Daily high/low/precip arrays." } ko: # description 생략 → en으로 폴백 name: "날씨 예보 가져오기" inputs: city: { name: 도시 } days: { name: 일수 } outputs: forecast: { name: 예보 } ``` ## ⑤ kit.yaml Tie the kit together. No auth, so this is all you need. ```yaml # my_weather/kit.yaml id: weather version: 0.1.0 display_name: "Weather" description: "Open-Meteo에서 도시별 일별 예보. 무인증." author: "you" license: "MIT" icon: icon.svg auth: type: none # ← category 없는 type:none = '항상 연결됨'으로 합성 (connect.py 불필요) ``` ## ⑥ Install → bake Run the Toast app first (the backend comes up at `http://localhost:18587`). Then install the kit: ```bash # 1) kit 설치 (개발 중, 디렉터리 경로로): curl -X POST http://localhost:18587/api/v1/kits/install \ -H "Content-Type: application/json" \ -d '{ "path": "C:/Users/you/kits/my_weather" }' # macOS/Linux면 "/abs/path/my_weather" ``` Now bake it like any dough: ```bash # 2) bake: curl -X POST http://localhost:18587/api/v1/doughs/weather.get_forecast/bake \ -H "Content-Type: application/json" \ -d '{ "inputs": { "city": "Seoul" } }' ``` You get back something like: ```json { "forecast": { "city": "Seoul", "dates": ["2026-06-16", "2026-06-17", "2026-06-18"], "high": [28.4, 29.1, 27.6], "low": [19.2, 20.0, 18.8], "precip": [0.0, 3.2, 11.5] } } ``` You built and ran a kit. > **TIP (If it breaks):** A missing class in `types.py` throws `ImportError` at kit load — create `types.py` first. `object`/`list` outputs require `model:` (or `schema:`). For deeper pitfalls and validation rules, see the Reference. Next → Core concepts --- # Core concepts > The four parts — tool, flour, dough, kit — the kind and class axes, dough YAML syntax, and how it differs from MCP and Zapier, in one place. ## The four core concepts > **What & why:** Grasp these four words and the rest of the document reads cleanly. | Term | What | Told apart by shape | |------|------|---------------------| | **tool** | one Python function (HTTP/disk/compute/parse) | `tools.py` symbol | | **flour** | YAML wrapping exactly one action | `action:` present, `steps:` absent | | **dough** | a composition wiring flours/doughs | `steps:` present, `action:` absent | | **kit** | the distribution unit of tool+flour+dough | `kit.yaml` manifest | There are two kinds of flour: - **tool flour** — `action: tool:` — wraps Python - **agent flour** — `action: agent:` — an LLM reasons over data > **What & why (Execution vocabulary):** **bake** = running a dough once. **donut** = the result record of that run (status, output, checkpoint). **closure** = one dough + every flour/dough it depends on (the unit of publish/import). **HITL** = Human-in-the-loop, a gate where a person approves mid-run. ## Two orthogonal axes > **What & why (Two orthogonal axes):** **kind** = `flour | dough` — inferred from shape (`action:` vs `steps:`). Writing `kind:` directly is rejected. **class** = by id shape — **Fixed** (kit-shipped) / **Custom** (`user.*`) / **Web** (`web..`). ## Dough anatomy — the syntax to learn once > **What & why:** The syntax common to every YAML, in one place. Learn this and the earlier examples all decode. - **`id` is the path** — `weather.get_forecast` → `…/weather/get_forecast/`. Dots are folder hierarchy. (User-owned doughs are `user.`) - **`verb` / `object`** — the two axes classifying a dough (`get` / `forecast`). Used for search and display. - **`inputs:` / `outputs:`** — pure schema. `{type, required, default, options, model, schema, display}`. `type` is `string | number | boolean | list | object`. Richer typing on `object`/`list` via `model:` (a dotted Pydantic reference) or an inline `schema:`. - **`${...}` reference syntax:** - `${inputs.symbol}` — an input of this dough - `${stepname.field}` — output of an earlier step (the step **name** = the **last segment** of the dough id) - `${item}` (each `as:` name) — the current item of `each:`/`all:` - **`action.with` / `action.to`** — the arguments passed to the tool / the output key that holds the result - **`box.yaml`** — all display text. **Required** for every flour and dough. ### outputs · action.to · return — how the three relate - **`outputs:`** = the _schema_ of the output (name, type, `model`/`schema`, `display`). - **`action.to:`** = _which output key_ the tool/agent result goes into (flour-internal wiring). - **`return:`** = which of those outputs to _expose outward (to the parent dough/caller)_. **Omitting it is a parse error.** `return:` is structurally required, and an empty `{}` raises a `RETURN_MISSING` validation error. ## How a Toast tool differs from MCP and Zapier > **What & why:** The first question a newcomer asks: "How is this different from MCP, from Zapier?" | | What you write | What comes with it | |--|----------------|--------------------| | **MCP** | one tool (a function) | One tool. The host only calls it. | | **Toast** | one tool (a function) | A tool plus the whole runtime: composition wiring, scheduling, HITL approval, OAuth connection, glaze (model choice), and catalog sharing — all free. | MCP exposes one tool to an agent. Toast **wraps that tool as a reusable part and mounts it on the runtime**. Write the function once, and scheduling, approval, auth, model choice, and sharing all follow on top. **Zapier and n8n** are "app A → app B" trigger-action wiring. Toast adds two things. First, it inserts **LLM reasoning** as a first-class step, the agent flour. Second, it **stacks the automation you built back as a part** to feed the next one. The result is not wiring but a part library that compounds. --- # What to build Author tool · flour · agent · composition · kit by hand, wire up authenticated connected-app kits, and assemble the Gmail → agent → Slack example. ## Deciding what to build > **What & why:** It forks here. What you build depends on your goal. | You want… | Build | |---|---| | A new capability — external system, computation, parsing | **tool** + tool flour (→ kit) | | Only an LLM judgment over data you already have (reasoning gap) | **agent flour** | | Wiring existing flours/doughs together | **composition** | | Packaging the above for distribution | **kit** | | A connected app that needs auth | **auth block** | | Driving a site through the browser | **Web dough** (recorder) | Authoring a new tool (the top row) is a first-class path here. --- # flour A flour wraps exactly one action. Two kinds — a tool flour (code) and an agent flour (LLM). A flour is one action. A `tool flour` wraps a Python tool with an input/output schema; an `agent flour` adds only an LLM judgment over data you already hold. ## tool flour — code Flow: write a function in `tools.py` → wrap it in a `tool flour` → bind via `action.tool`, wire I/O with `with`/`to`. ### tool (tools.py) - An ordinary (async) Python function. **The return-type annotation is the published contract.** The loader extracts a JSON schema from it. - Raise the built-in `PermissionError` on auth failure. The host turns it into a `kit.yaml`-based response. Don't hand-build auth-error JSON yourself. - Read credentials/files through host-injected `_core`: `from _core.profile import profile_dir`, `from _core.tokens import read_tokens, write_tokens`. Don't compute profile paths yourself. - **A kit never imports `app.*`.** Allowed: stdlib · its own files · sibling kits declared via `requires:` · shipped deps (`pydantic`/`httpx`/`ruamel`) · `from _core.`. ### tool flour Wraps a tool and attaches an input/output schema. `action.tool: .` + `with`/`to`. **Every `with` key must be a real function parameter, and every input must be referenced at least once via `${inputs.x}`** (enforced by the gate in Gotchas). The canonical tool flour shape. It binds the Python symbol (action.tool) and declares inputs/outputs: ```yaml id: weather.get_forecast # ← id가 곧 경로 (점 = 폴더 계층) version: 0.1.0 icon: cloud source: kit verb: get object: forecast inputs: city: { type: string, required: true, default: Seoul } days: { type: number, required: false, default: 3 } outputs: forecast: type: object model: weather.types:Forecast # ← object/list 출력은 model: (또는 schema:) 필수 display: raw action: tool: weather.get_forecast # tools.py:get_forecast 바인딩 with: city: ${inputs.city} # ${...} = 참조 문법 days: ${inputs.days} to: forecast # 결과를 'forecast' 출력에 저장 return: forecast: ${forecast} # ← 외부로 노출. 생략 불가 (필수) ``` > **Rule:** `model:` is not optional. A kit-shipped flour's `object`/`list` output must carry either `model:` (a dotted Pydantic reference) **or** an inline `schema:` (validation R11). With neither, install/CI validation rejects it. Only scalar outputs like `string`/`number`/`boolean` may omit it. ## agent flour — LLM reasoning Flow: input `forecast` → agent reads it (no tool call) → emits structured JSON. ### agent flour `action:` is an `agent:` prompt. It reads the input data and emits **only structured JSON**, no tool call. This is the archetypal way to fill a "reasoning gap." Extend the weather kit with an agent flour that reads the forecast and gives umbrella/outfit advice. No new tool. It adds only an LLM judgment over data you already hold (the forecast). ```yaml id: weather.umbrella_advice # 같은 weather kit에 함께 싣는 agent flour version: 0.1.0 verb: advise object: forecast inputs: forecast: type: object required: true model: weather.types:Forecast # tool flour의 출력 그대로 받음 outputs: advice: type: object # ← object/list 출력엔 schema/model 필수 (아래) model: weather.types:UmbrellaAdvice display: raw action: agent: | # Worker: Umbrella & Outfit Advice Read the daily forecast and give practical advice. Your entire reply IS the output: a single JSON object in the shape below. Do NOT call tools. ## Forecast ${inputs.forecast} ## Output Return JSON only: { "umbrella": bool, "outfit": "", "summary": "" } to: advice return: advice: ${advice} ``` Bake it and you get back something like: ```json { "advice": { "umbrella": true, "outfit": "Light jacket; carry a compact umbrella for the afternoon.", "summary": "Mild now, rain building midweek — take an umbrella by day 3." } } ``` > **Gotcha:** An agent flour's object/list output must carry `schema:` or `model:`. With a schema, bake locks onto provider-native structured output (LightSession); if the structured emit is still empty after up to 3 retries, it fails loudly with `STRUCTURED_EMIT_EMPTY`. Without a schema it falls back to the free-text path and may **silently bind an empty value**. The `AGENT_OBJECT_OUTPUT_NEEDS_SCHEMA` save-time check exists to prevent exactly that. Scalar outputs (`string`, etc.) need no schema since free-text is their normal path. > **Multi-output caveat:** The schema resolves only when `to:` is a single string output key. Binding to multiple outputs with a dict, like `to: {k1: ..., k2: ...}`, won't pick up the schema and you lose provider-native structured output. Keep an agent flour to a single output. --- # dough Wire existing flours and doughs together. A composition has steps only, no action — and a step takes exactly three shapes. Flow: `get_forecast` → `umbrella_advice` → `return`, wired with three step shapes only: `dough` / `each` / `all`. ## composition (dough) `steps:` only, no `action:`. A step takes **exactly three shapes**: - `dough: ` — call another flour/dough - `each: { each: , as: , do: [...] }` — **sequential** iteration (cap=1) - `all: { all: , as: , max_parallel: , do: [...] }` — **parallel** iteration (default cap 8, hard cap 32) Inline `tool:`/`agent:`/`llm:`/`web:` are **forbidden** — lift into a flour first. There is no `if:`/`when:`/`on_error:` either (see the branching callout below). Wire the weather kit's tool flour and agent flour into a "daily briefing" dough. (Reference a step's output by the **last segment** of its dough id: `weather.get_forecast` → `${get_forecast.…}`) ```yaml # weather/daily_briefing/dough.yaml — 직선 파이프라인: 예보 → 조언 id: weather.daily_briefing version: 0.1.0 verb: brief object: forecast inputs: city: { type: string, required: true, default: Seoul } outputs: advice: { type: object, model: weather.types:UmbrellaAdvice, display: raw } steps: - dough: weather.get_forecast with: { city: ${inputs.city}, days: 3 } - dough: weather.umbrella_advice with: { forecast: ${get_forecast.forecast} } return: advice: ${umbrella_advice.advice} ``` Many cities at once (fan-out): all runs in parallel, order-independent. ```yaml steps: - all: ${inputs.cities} # 예: ["Seoul", "Tokyo", "Paris"] as: city max_parallel: 5 do: - dough: weather.get_forecast with: { city: ${city}, days: 3 } - dough: weather.umbrella_advice with: { forecast: ${get_forecast.forecast} } ``` > **NOTE:** If order matters, use `each:` instead of `all:` (same grammar, sequential). ## Branching & boundary > **There is no if step — branch three ways:** **1. Inside an agent flour** — branches that turn on reasoning belong here. (compositions have no `if:` at all.) **2. Lift to a value-returning flour and consume linearly** — `basic.condition` returns the value of one of `then`/`else`, and you flow that straight downstream. (`basic.pick_non_null`, `basic.filter` are the same family.) **3. Skip via an empty list** — when `basic.gate_if_any`/`basic.filter` empties a list, feed that list to `each:` for 0 iterations = a whole skip. > **kit-shipped dough boundary:** A dough shipped inside a kit may wire only **its own kit + floor kits (`basic`/`webengine`/`thinking`)**. A combination crossing peer kits is a _recipe_ — catalog/user (`user.*`) territory, never shipped inside a kit. (The Gmail→Slack in the connected-app example is exactly a recipe.) --- # kit Compose with kit.yaml + connect.py, add a new tool, and let provides: auto-derive from the flour tree. ## Package a kit Flow: `tools.py` (symbol) → flour directory (`dough.yaml`+`box.yaml`) → `types.py` (if new model) → `kit.yaml` (bundle). `kit.yaml` (id·version·auth) + a `connect.py` when needed. Adding a new tool = add a symbol to `tools.py` + drop a flour directory next to it (`dough.yaml`+`box.yaml`) + add to `types.py` if `model:` points at a new type. **No `provides:` declaration** — it's auto-derived from the flour tree. ## Authentication (OAuth / BYOK) > **What & why:** The Quickstart weather kit was unauthenticated. Real connected-app kits (Gmail, Slack, Notion) need auth. Declare it with the auth: block in kit.yaml. Flow: the auth: block in kit.yaml → connect.py (check/connect) → tokens used in the tool via _core.tokens. ### auth block — managed OAuth2 ```yaml # OAuth2 (관리형 — postlab이 client 제공). 실예: postlab.google.gmail auth: type: oauth2 category: managed oauth2: scopes: [ "https://www.googleapis.com/auth/gmail.readonly" ] byok: false # true면 사용자가 자기 client_id/secret 입력 (BYOK) authorize_url: https://accounts.google.com/o/oauth2/auth token_url: https://oauth2.googleapis.com/token adapter: my_kit.auth:get_auth_client # oauth2 흐름엔 adapter 필수 (module:symbol) connect: my_kit.connect # 모듈 경로 (심볼 없음) requires: [ my_parent_lib ] # 부모 라이브러리 kit이 있으면 ``` For **BYOK** (the user brings their own OAuth app), add `category: both` + `oauth2.byok: true` + `setup_url` (a developer-portal link). Slack is the real example: ```yaml auth: type: oauth2 category: both oauth2: scopes: [channels:history, chat:write] byok: true setup_url: https://api.slack.com/apps # byok:true 일 때만 의미 있음 authorize_url: https://slack.com/oauth/v2/authorize token_url: https://slack.com/api/oauth.v2.access adapter: postlab.slack.auth:get_slack_auth_client connect: postlab.slack.connect ``` ### Tokens inside a tool Read tokens through _core, never by hand. Raise the built-in PermissionError on auth failure. The host turns it into a kit.yaml-based response. ```python from _core.tokens import read_tokens # 프로필 토큰 저장소 from _core.profile import credentials_dir # 자격증명 경로 (직접 계산 금지) async def list_messages(query: str, **_) -> MessageList: tokens = read_tokens("my_kit") if not tokens: raise PermissionError("not connected") # → 호스트가 연결 유도 ... ``` ### connect.py `connect.py` exposes two functions — both **argument-free** (the kit self-discovers its profile via `_core.profile`): - `async def check() -> AuthStatus` — returns the current connection state. - `async def connect() -> AsyncGenerator[AuthEvent, None]` — an async generator that **yields** connection events (started/waiting/done/cancelled/error). The host streams them over SSE. Don't hand-roll the OAuth machine. The real OAuth base is `OAuthProvider` (ABC) + `LoopbackOAuthMixin` in `_core.oauth_base`, and the **kit's auth client (adapter)** inherits from it. `connect.py` merely delegates to that client; it does not inherit oauth_base itself. `POST /api/v1/kits/{id}/connect` triggers a connection and streams the events above over SSE. The app's Settings panel drives this flow. To run the killer example recipe, connect both the gmail and slack kits first. > **NOTE:** A local desktop kit (e.g. KakaoTalk) uses `auth.type: none` **+ `category: local`**. `category: local`/`device` forces the host to call the kit's real `connect.check()` rather than synthesizing a state. (Conversely, `type: none` without `category` is synthesized as always-connected, as in Quickstart.) ### auth enums at a glance | Field | Values | |-------|--------| | `auth.type` | `oauth2` · `api_key` · `bearer` · `credentials` · `none` (default) | | `auth.category` | `managed` · `byok` · `both` · `local` · `device` · `mcp` (optional) | | `oauth2` (required keys) | `authorize_url`, `token_url` | | `oauth2` (optional) | `scopes`[], `pkce` (default false), `byok` (default false), `setup_url` (byok only) | | `auth.adapter` | `module:symbol` — required for the oauth2 flow | | `auth.modes` | usually auto-derived (oauth2+byok→`[official, personal]`, oauth2 managed→`[official]`, none→`[]`, otherwise api_key/bearer/credentials→`[personal]`) — not set by hand | --- # dough-creator plugin A Claude Code plugin that builds Toast automations from natural language. Describe what you want instead of hand-writing YAML; it authors the kit/flour/dough, verifies with a real bake, then deploys. Where the in-app OVEN only **composes existing capabilities**, this plugin also creates **the capabilities themselves** — full kits with Python tools. Same artifacts as hand-authoring, with the writing automated. ```bash /create "when a new video drops on this channel, summarize it to Telegram" /test # register into Toast + bake-verify (repairs failures in place) /publish # deploy the verified automations ``` ## Install Install from the Claude Code marketplace: ```bash # 1) add the marketplace (once) /plugin marketplace add postlabs/claude-code-plugin # 2) install /plugin install dough-creator@postlabs-plugins ``` Source: postlabs/claude-code-plugin (https://github.com/postlabs/claude-code-plugin/tree/main/dough-creator). It uses the Python interpreter on your `PATH` (or one you point `TOAST_PYTHON` at). Deps per command: | Command | Toast? | Deps | |---------|--------|------| | `/create` | No | `pydantic · ruamel.yaml` | | `/test` · `/publish` | Yes (running) | `+ mcp · httpx · httpx-sse` | ## /create — build Describe what you want; it authors the artifacts in your cwd and runs static validation + tool unit-runs locally. Works without Toast, and never bakes. ```text .// ├── kits// # new Python kit → install → copied into backend profile └── doughs// # user dough → publish → POST/PUT /doughs ``` > **NOTE:** **Your cwd is the source of truth.** Everything lands under `.//` — you can read, version, and edit it. Toast holds only the published runtime copies. ## /test — verify Registers the workspace into a running Toast and bakes the root doughs. On failure it repairs in place — fix → re-register → re-bake — until green, then stamps provenance verified. | Level | Reached by | Means | |-------|-----------|-------| | `static` | `/create` | authored + static-validated + unit-run. Not yet run on the real engine; not deployable | | `verified` | `/test` | a real root-dough bake ran green. Deployable | ## /publish — deploy Deploys only verified artifacts (test gates deploy) and hands over the management controls. Publishing is validation: a 422 carries the validation_errors to fix before republishing. > **TIP:** Discovery, validation, and baking go through **peel MCP** (HTTP to the backend); kit binding and user-dough registration use script wrappers (`kit_lifecycle.py` · `dough_publish.py`). Kits install and hot-reload without a restart. ## v1 scope · vs hand-authoring > **WARNING:** **v1 is auth-less kits only** (`auth: type: none`) — pure compute, public APIs, local files. **OAuth kits (connect flows) still need hand-authoring** (→ kit · auth). Browser automation is delegated to the action-creator plugin. So the two paths complement each other — **fast** with this plugin, **full control / OAuth / understanding** with hand-authoring (Build). Both produce the same kits and doughs, baked by the same Toast. --- # Install & automate Turn a one-off bake into an automation — install, schedule, and gate doughs, and learn the operational limits that shape runtime. ## Install & dev loop The edit → re-run cycle. Know which changes apply instantly and which need a restart. - **Install (dev):** `POST /api/v1/kits/install { path }` — bind straight from a directory. - **Install (deploy):** `POST /api/v1/kits/install/zip` — upload a .zip. Source lands in `{profile}/kits//`, then is copied and bound into `{profile}/doughs//`. - **Run:** `POST /api/v1/doughs//bake { inputs }` (or bake from the app UI). > **NOTE:** **dough.yaml / box.yaml (YAML)** loads the moment you copy it — no restart. **tools.py (Python)** needs a reinstall or restart. The module is already imported, so editing the file on disk alone won't take. Changed a function signature or logic? Reinstall the kit or restart the backend. ## Schedule — let it run itself Turn a one-off bake into an automation. Attach a trigger to a dough and the scheduler bakes it for you (`POST /api/v1/oven/schedules`). Four trigger types: Flow: define a trigger → `POST /api/v1/oven/schedules` → the scheduler bakes it for you. | Trigger | When | Example | |---------|------|---------| | `daily_at` | Specific times/days (the 80% case) | `{ times: ["08:50"], days: ["mon","tue","wed","thu","fri"] }` | | `interval` | Every N minutes (+ active window) | `{ every_minutes: 30, start_time: "09:00", end_time: "17:00" }` | | `cron` | Arbitrary cron expression (power user) | `{ expression: "50 8 * * 1-5" }` | | `once` | Once | A single run at a set time | ```jsonc POST /api/v1/oven/schedules { "dough_id": "weather.daily_briefing", "label": "아침 날씨 브리핑", // ← required, 400 if missing "trigger": { "trigger_type": "daily_at", "times": ["08:00"], "days": ["mon","tue","wed","thu","fri"] }, "inputs": { "city": "Seoul" }, "enabled": true } ``` > **WARNING (label is required):** Omit `label` and it is rejected with `HTTP 400 "'label' required"` (same for `dough_id`/`trigger`). `label` is the **human-facing schedule name** shown on the oven page. ## HITL — human approval mid-run Any step can carry a `confirm: bool` field (flour action, `dough:`, `each:`, `all:` — all of them). A step with `confirm: true` **halts the bake right before it runs** (checkpointed with the donut, the bake-result record, in state `paused`, unwound via `PauseSignal`) and waits for user approval. Set `confirm_steps: true` in the glaze to gate every step at once. Resume with a separate call: ```bash POST /api/v1/doughs/{dough_id}/resume { "donut_id": "", "action": "confirm" } # confirm | skip | cancel ``` - `confirm` — re-run from the halted step, but don't re-gate that one step. - `skip` — skip the halted step and continue from the next. - `cancel` — mark the donut as failed. > **WARNING (Two cautions):** (1) `confirm`/`skip` only resume if the dough **version is unchanged**. Edit the dough while it is paused and you get `DOUGH_CHANGED_SINCE_PAUSE` (`cancel` always works). (2) **Nested HITL is unsupported.** A confirm gate inside a sub-dough (depth>0) is not restored, because resume only reads the outer donut's checkpoint. **Put confirm gates on top-level composition steps.** ## Operational limits (bake knowing them) **Default step timeout = 30,000ms (30s).** The engine wraps each leaf tool call in `asyncio.wait_for`. For tools that legitimately exceed 30s (many API calls, e.g. token refresh or a sweep), raise it via the flour's `action.timeout_ms` (integer milliseconds) — shipped example: refresh-family at `120000`. ```yaml action: tool: my_kit.refresh_docs timeout_ms: 120000 # this leaf action only, 120s with: { ... } to: result ``` **Two clocks, separate.** The 30s above is the _engine-side_ wrapper. A timeout the kit tool sets on its own httpx/client runs **independently inside it**. The engine does not silently truncate or override the tool's own timeout — whichever **finishes first** wins. **`all:` parallelism limits.** The default concurrency cap is **8** (when `max_parallel` is unset), the hard ceiling is **32**. The validator rejects a `max_parallel` outside `1..32`, and the executor also clamps to 32. `each:` is always sequential (cap=1); only `all:` is parallel. | Limit | Value | |-------|-------| | Default step timeout | `30,000ms (30s)` | | Per-leaf timeout override | `action.timeout_ms` | | all: default concurrency cap | `8` | | all: hard ceiling | `32` | | each: concurrency | always sequential (cap=1) | --- # Publish & fork Publish your dough so other builders can use it as a part. A published dough gets forked into someone else's composition. ## Publish (catalog) ### What works now — publishing a dough closure Only the closure of a **custom (`user.*`) dough** can be published. | Action | Endpoint | |--------|----------| | Publish | `POST /api/v1/doughs/{id}/publish` | | Browse | `GET /api/v1/catalog` | | My published | `GET /api/v1/catalog/mine` | | Import (fork) | `POST /api/v1/catalog/{entry_id}/import` | | Unpublish | `DELETE /api/v1/catalog/{entry_id}` | `POST /api/v1/doughs/{id}/publish` exports the dependency closure into the public catalog (login required). `import` clones the closure into your profile. The first install bumps `import_count` by 1 (owner excluded); reinstalls don't inflate the counter. A `provenance.yaml` records the original author (`owner_handle`) and `source_version`. ### What does not work yet — kit publishing (v1 unimplemented/planned) **No HTTP endpoint puts a kit itself on the catalog.** The only publish route is the `POST /doughs/{id}/publish` (dough closure) above. The export path also hardcodes the publishing tier as `AuthorTier.USER`, while kit publishing requires `THIRD_PARTY`. So kit export is **always rejected** by policy (`ValueError: only custom (user.*) doughs can be exported`). Kit sharing is structurally blocked right now — not for lack of a route, but because the policy seed is a placeholder. > **WARNING:** Export only captures dotted kit references in a composition as dependencies. If a user agent flour calls a kit from inside its prompt, it isn't statically visible, so it doesn't appear at export time and surfaces only at runtime (not a bug, an accepted limitation). ## Fork & provenance When your dough is forked, provenance and an import count travel inside it. Your part runs inside another builder's automation. This is where the Toast snowball plays out. > **NOTE:** Search, ranking, and monetization are future work. Today: browse/mine/import/unpublish plus provenance/import_count. --- # Examples From the connected-app example (Gmail search → agent triage → Slack) to the example gallery and finished scenarios — how the techniques assemble in practice. ## Connected-app example — Gmail search → agent triage → Slack > **NOTE:** Where Toast shines. Weather shows what a tool is; the real value is automation that wires connected apps (Zapier/n8n-style app-to-app plus LLM reasoning). Search unread Gmail → an agent classifies and summarizes by importance → send a digest to Slack. Because it crosses peer kits (gmail + slack), this is not a kit-shipped dough but a **recipe** — `user.*` territory (the boundary rule in composition). Both the gmail and slack kits must be connected (connection flow → `POST /kits/{id}/connect` in Authenticated kits). `Gmail search → agent triage → Slack send` ### ① agent flour — email triage (inline schema:) Instead of `model:`, an **inline `schema:`** locks the object output without a separate `types.py`. ```yaml # user/triage_emails/dough.yaml id: user.triage_emails version: 0.1.0 verb: triage object: message inputs: messages: type: object required: true model: postlab.google.gmail.types:GmailMessageList # Gmail flour 출력 그대로 받음 outputs: triage: type: object display: raw schema: # 인라인 JSON 스키마 = model: 의 대안 type: object properties: summary: { type: string } important: { type: array, items: { type: string } } required: [summary] action: agent: | # Worker: Inbox Triage Classify the unread messages by importance and write a short Slack-ready digest. Your entire reply IS the output JSON. Do NOT call tools. ## Messages ${inputs.messages} ## Output Return JSON only: { "summary": "", "important": ["", ...] } to: triage return: triage: ${triage} ``` ### ② composition — Gmail → agent → Slack ```yaml # user/inbox_triage_slack/dough.yaml id: user.inbox_triage_slack version: 0.1.0 verb: triage object: inbox inputs: query: { type: string, required: true, default: "is:unread newer_than:1d" } channel: { type: string, required: true } # Slack channel id outputs: summary: { type: string, display: markdown } steps: - dough: postlab.google.gmail.list_messages with: { query: ${inputs.query}, limit: 30 } - dough: user.triage_emails with: { messages: ${list_messages.list_messages} } - dough: slack_send_message with: channel_id: ${inputs.channel} text: ${triage_emails.triage.summary} return: summary: ${triage_emails.triage.summary} ``` A step's name is the last segment of its dough id — `postlab.google.gmail.list_messages` → `${list_messages.…}`, `user.triage_emails` → `${triage_emails.…}`. Each flour's output key follows too (`list_messages` returns a `list_messages` output, `triage_emails` returns a `triage` output). > **WARNING:** `slack_send_message` is a prefix-less bare id — some legacy kits actually ship it that way, so the example follows suit. New kit distributions should follow the `.` form. ### ③ Down to the reply — human confirm before send (HITL) To attach an auto-reply to important mail but have **a human approve right before sending**, add `confirm: true` to that step: ```yaml steps: - dough: my_mail.draft_reply with: { thread: ${inputs.thread} } - dough: my_mail.send_message confirm: true # ← 보내기 전 사람이 확인 (HITL) with: { draft: ${draft_reply.draft} } ``` ## Example gallery How the techniques so far actually assemble. (This "gallery" is a set of learning examples — separate from the publish catalog, which is the publishing marketplace.) ### Base patterns | Pattern | Technique | Where | |---------|-----------|-------| | tool (external API) | HTTP call → tool flour → kit | `weather.get_forecast` | | agent flour (reasoning gap) | LLM judgment over held data, no new tool | `weather.umbrella_advice` | | composition + fan-out | wire flours / each·all iteration | `weather.daily_briefing` | | connected app + agent + HITL | Gmail → agent → Slack, approval gate | `user.inbox_triage_slack` | ## Finished scenarios ### Finished scenarios (assembly recipes) How those patterns attach to real goals — three of them. - **(a) Morning meeting brief** — every weekday at 8, today's schedule on one page. `calendar.get_today_events` (tool flour) → `calendar.brief_agent` (agent flour) → wired in a composition with a `daily_at` schedule. **Same skeleton** as the Quickstart weather stack, just a calendar source. - **(b) Inbox triage → Slack** — schedule the connected-app example's `user.inbox_triage_slack` with `daily_at`, and attach HITL to the reply flow. (= a concrete "connected app + agent + HITL".) - **(c) Competitor price monitoring** — N pages every 30 minutes. Record a per-site `web..read_price` (Web dough) with the recorder → scrape N in parallel with `all` in a composition → detect changes with `price.diff_agent` → `interval { every_minutes: 30, start_time: "09:00", end_time: "18:00" }`. > **TIP:** A one-off single instruction gets no benefit from selector caching. The recording investment pays off only on repetition (schedule / bulk / sub-action reuse). --- # Fields · glaze · ground truth One place to look up every field scattered through the guide. Not a read-through. Come back here mid-authoring when you blank on a field name. ## dough.yaml — full fields | Field | Required | What | |-------|----------|------| | `id` | ✓ | dough identifier = path (`weather.get_forecast`) | | `version` | ✓ | semver | | `verb` / `object` | ✓ | the two classification axes (`get`/`forecast`) | | `icon` | – | UI icon name | | `source` | – | `kit` (kit-shipped) | | `inputs` | – | input schema (see inputs/outputs below) | | `outputs` | – | output schema (see inputs/outputs below) | | `action` | flour | exactly one of `tool:` or `agent:` + `with` + `to` (mutually exclusive with `steps`) | | `steps` | dough | composition step list (mutually exclusive with `action`) | | **`return`** | **✓** | maps the outputs exposed externally. **No default; omitting it is a parse error**. An empty `{}` yields `RETURN_MISSING` | Never write `kind:`. `action:` infers a flour, `steps:` infers a dough. **kind and class are separate axes.** **kind** (flour/dough) comes from shape; **class** comes from the id — **Fixed** (kit-shipped, `.`), **Custom** (`user.*`), **Web** (`web..`). ## inputs / outputs fields ```yaml inputs: city: type: string # string | number | boolean | list | object required: true default: Seoul options: [Seoul, Tokyo] # (optional) allowed-value enum outputs: forecast: type: object model: weather.types:Forecast # object/list need model: or schema: (kit flour) display: raw # (optional) render mode — see below ``` Six `display` modes (`markdown · data_table · items_table · browser_tab · app_window · raw`). Omit it for a per-type default: | type | default display | |------|-----------------| | `string` | `markdown` | | `list` | `data_table` | | `number` / `boolean` / `object` | `raw` | | `date` / `datetime` | (no default) | > **NOTE:** `items_table` is **not the default of any type**. Always explicit: requires `type: list` and a source that is an `each:`/`all:` output. `browser_tab`/`app_window` are explicit-only too. > **TIP:** `object`/`list` outputs **require** `model:` or `schema:` (kit flour). `model:` is a dotted Pydantic reference (`weather.types:Forecast`). ## kit.yaml — full fields Only `id` and `version` are required; everything else has a default. (No `auth` block at all = `type: none`.) | Field | What | |-------|------| | `id` (required) | kit identifier | | `version` (required) | semver | | `display_name` · `description` · `author` · `homepage` · `license` · `icon` | UI metadata | | `auth` | `KitAuth` block (default `type: none`). See the auth enum table. | | `connect` | module path with **no symbol** (`postlab.google.gmail.connect`). The host calls top-level `check`/`connect`/`disconnect` | | `routes` | `module:symbol` — a FastAPI `APIRouter` inside the kit. Only declaring kits get their HTTP routes mounted dynamically. e.g. `postlab.slack.routes:router` | | `requires` | list of sibling kit ids it depends on (`[ postlab.google ]`) | | `python_deps` | PyPI packages the kit uses (`[ slack_sdk ]`). Must be declared for the frozen build. | | `delivery` | `bundled` (default) or `cloud`. Any other value is rejected. | | ~~`provides`~~ | **forbidden**. Including it raises a ValueError at parse time. Derived from the flour tree. | ```yaml id: postlab.slack version: 1.0.0 display_name: Slack description: Send and read Slack messages author: Postlabs auth: # KitAuth block (default type: none) type: oauth2 connect: postlab.slack.connect # module path, NO symbol routes: postlab.slack.routes:router # module:symbol — FastAPI APIRouter requires: [ postlab.google ] # sibling kit ids python_deps: [ slack_sdk ] # PyPI packages — required in frozen build delivery: bundled # bundled (default) or cloud ``` > **NOTE:** `connect` is a symbol-less module path; `routes` and `auth.adapter` are `module:symbol`. All three must **start with the kit id** to pass validation. ## ${ref} quick reference | Form | Points to | |------|-----------| | `${inputs.x}` | this dough's input | | `${stepname.field}` | an earlier step's output (step name = the **last segment** of the dough id) | | `${item}` (each as: name) | the current item of `each:`/`all:` | ### Naming & Pydantic models - **id = path.** Dots are the folder hierarchy. Kit-shipped is `.`, user-owned is `user.`. - **`verb` / `object`** — short verb/noun. Drives search, display, and dedup. - **`types.py` lives at the kit level.** Pydantic models referenced by `model:` are defined as `pydantic.BaseModel` in the kit-root `types.py` (or a sibling module) and pointed to with `.types:ClassName`. No `app.*` imports. No `.py` inside a flour directory. ## glaze — model selection for agent flours **glaze** decides which LLM provider and model an agent/llm step uses. `dough.yaml` has no model field. Model selection is glaze territory. At bake time an agent flour reads `glaze.provider` (default `"auto"`) and `glaze.model` (`None` = provider default) and passes them to the session. A builder sets the model in three ways, in priority order: - **bake request-body override** — `bake { glaze: { provider, model } }` (applies to that whole bake). - **per-dough / per-flour `glaze.yaml`** — a `glaze.yaml` placed next to a `dough.yaml`. A flour is also a dough (id = folder), so a `glaze.yaml` beside one flour makes **only that flour** use a different model. Set via `PUT /api/v1/doughs/{id}/glaze`. - **profile default** — `PUT /api/v1/doughs/settings/glaze`. ```yaml # glaze.yaml — placed next to a dough.yaml (or a single flour) provider: anthropic model: claude-sonnet-4-5 temperature: 0.2 max_tokens: 4096 ``` If the request gives no glaze, the baker resolves the glaze stored on the dough; if there is none either, an empty glaze falls back to the system default (`LLM_PROVIDER`). The builder-control fields glaze exposes: `provider`, `model`, `temperature`, `max_tokens`, `variables`, `confirm_steps`. > **TIP:** **The builder controls model selection** — through glaze (request override > `glaze.yaml` beside the flour > profile default), not `dough.yaml`. ## 13. Deep cuts & gotchas Spots where a build passes validation yet silently breaks at runtime. (The section intermediate-plus builders read while debugging.) ### Authoring-contract gates (validate_kit_tree — enforced at install/CI) - **with-keys ⊆ function signature.** A `with` key absorbed only by `**kwargs` is silently dropped at bake (the bug that stalled Gmail at 20). Keep command `*Params` models at `extra="forbid"` so stray keys fail loudly. - **No dead inputs.** Declare an input in `inputs:` but never reference it via `${inputs.x}` and it is rejected. ### Branching (basic.condition) precise contract - inputs: `value` (string, required), `equals` (string, optional), `then` (object, required), `else` (object, optional); output `result` (list, display raw). - It is a **pure `==` equality check** — no predicate/expression language. If `value == equals` it returns `then`, else `else` **as that value itself** (the value of one branch, not a filtered list). ### Schema / output gotchas - **A typo in a `model:`/`schema:` ref does not raise.** Resolution failures are all swallowed into `None`, and the agent flour is silently downgraded to free-text. Validation only checks that the ref is _non-empty_ (present), not that it is _an importable BaseModel_ — a wrong ref passes save validation and fails silently as an empty value at bake. Confirm the model really exists in `types.py`. - **`items_table` is not the default.** A plain `list` defaults to `data_table`. Set `items_table` explicitly, keep it `type: list`, and make the output's source step `each:` **or** `all:` (per-iteration status rows). Otherwise `OUTPUT_DISPLAY_REQUIRES_EACH`. ### Deploy / loading gotchas - After editing kit source, if you don't **regenerate `.kit-hash`** (`scripts/generate_kit_hashes.py`), a restart still loads the stale install. - Declare a kit's Python dependencies **explicitly** (`kit.yaml::python_deps` or engine requirements). In a frozen build, kit-only imports aren't analyzed and break silently. - Live bakes read the **installed profile** (`…/Toast/profiles/

/doughs/`). YAML applies on copy; **`tools.py` changes need a reinstall/restart** (Install & dev loop). - Tool code lives **only in `tools.py` (or a sibling concern module).** A `.py` inside a flour directory is forbidden — the chokepoint that stops an agent from scattering a bespoke `.py` per flour. ## Ground truth (the exact rules live in code) This guide summarizes the rules so you move fast. Where they conflict, the code wins. | What | Path | |------|------| | Authoring permission matrix | `docs_sh/authoring_policy/principals.md` | | kit authoring contract | `src/backend/app/kits/CLAUDE.md` | | Manifest schema | `app/kits/manifest/manifest.py::KitManifest` | | Loading (copy→sys.path→import) | `app/kits/loading/loader.py (load_all_kits)` | | Build guide (agent constitution) | `src/backend/kits/thinking/guide_build/dough.yaml` | | Validation rules | `app/doughs/validation/ (engine.py, rules.py, checks.py)` | | Execution (schema resolve, timeout, glaze) | `app/doughs/execution/ (actions.py, executors.py, baker.py)` | | Schedule (triggers, labels required) | `app/scheduler/ (api.py, models.py)` | | Vocabulary single source | `repo-root CLAUDE.md → "Dough Engine vocabulary"` | ---