Playbook spec format
Every playbook is one YAML file in public/playbooks/<sector>/<slug>.yaml, served at
https://developer.origamy.io/playbooks/<sector>/<slug>.yaml. The docs page for it is rendered
from the same file, so the two can’t disagree. The catalog of all playbooks is
/playbooks/index.json.
The copilot and the Origamy MCP server read these files: list_playbooks returns the catalog,
and plan_playbook asks the questions, fills in the answers and turns the objects into a
changeset of drafts. Every item is a body for an existing admin API endpoint.
Top level
Section titled “Top level”spec_version: 1id: base.first-contact-follow-up # <sector>.<slug>, matches the file pathtitle: First-contact follow-up with call taskssector: base # base | church | schools | retail | lending | saassource: kind: engagement # engagement (copied from a real build) | synthesized date: 2026-10-31 # when the engagement was built ref: eng-2026-10-church-a # an opaque engagement label, never a customer name notes: …derived_from: church.first-timer-to-member # optional: the playbook this one generalisesvocabulary: { person: person, people: people, group: team }summary: …when_to_use: [ … ]requires: data_in: [ { how: sheet_sync, what: … } ] # how the data gets in, see below channels: [ { channel: sms, for: …, needs: … } ] events: [ … ] # events the playbook reads; your app or forms must send them traits: [ … ]parameters: [ … ]timings: [ … ] # human-readable, may use {{ params.* }}customer_must: [ … ] # the "what you need to do" listobjects: [ … ]eval: { … }Where the data comes from
Section titled “Where the data comes from”requires.data_in is required: one entry per way the playbook’s data reaches Origamy, so a
small business can tell at a glance whether it needs a developer.
how |
Meaning |
|---|---|
public_form |
An Origamy form people fill in |
booking_form |
An Origamy booking form (booking.created, booking.cancelled) |
staff_form |
A form the business’s staff fill in, matched by phone |
sheet_sync |
A Google Sheet or Excel file synced as People |
csv_import |
A CSV upload, as People or Events |
whatsapp_replies |
Inbound WhatsApp messages as events |
app_events |
Events from the business’s app or website: needs a developer |
webhook |
Another system posting to a webhook source |
Patterns for small businesses explains each one.
Parameters
Section titled “Parameters”A parameter is one answer the playbook needs. About five are asked (ask: true); the rest
have defaults the copilot only raises if the customer does.
- key: follow_up_team # snake_case, used as {{ params.follow_up_team }} ask: true question: Who calls new contacts? help: Optional one line under the question. label: Follow-up team # short name for tables type: team_name default: Follow-up team # required when ask is false example: Admissions # shown when there is no defaultTypes: string, text, email, team_name, timezone, country_code, duration (2h,
3d), hours, days, cron (five fields, in the report’s time zone), time, boolean,
number, enum (with options), list (repeat objects with for_each), event_name,
trait_key, url.
A default can come from the workspace: {{ workspace.timezone }}, {{ workspace.country }}.
Placeholders
Section titled “Placeholders”The planner replaces three kinds of placeholder before it calls the API. Everything else in
{{ }} is Liquid and is left for the message renderer.
| Placeholder | Becomes |
|---|---|
{{ params.key }} |
The answer to that parameter. It may sit inside a Liquid tag: {{ traits.first_name | default: "{{ params.greeting_fallback }}" }}. |
{{ ref.obj }} or {{ ref.obj.id }} |
The id of an object created earlier in the same changeset. |
{{ ref.form.url }} |
A form’s or dashboard’s public link. |
{{ item.key }} |
A field of the current list item, inside a for_each object. |
{{ workspace.id }}, .name, .timezone, .country |
Read from the workspace. |
{{ workspace.today }}, .tomorrow |
The date (YYYY-MM-DD) in the workspace’s time zone when the playbook is installed. Schedules start {{ workspace.tomorrow }}T09:00. |
{{ workspace.default_sender }}, .phone |
The workspace’s default email sender and its company-profile phone. When either is empty, the planner asks instead. |
Liquid the messages may use: {{ traits.* }}, {{ properties.* }} (the triggering event’s
properties, and task_outcome / task_note after a task), {{ event }} (the event’s name),
{{ now }}, {{ first_name }} and the other recipient aliases, {{ company.* }},
{{ brand.* }}, and in email only {{ unsubscribe_url }} and {{ view_in_browser_url }}.
The validator rejects anything else inside {{ }}.
Objects
Section titled “Objects”objects is a list in dependency order: an object can only reference objects above it.
- ref: wf_call # snake_case, unique in the file kind: workflow about: One line for the docs table. when: { param: send_sms, equals: true } # optional: only when the answer matches # (or { param: call_team, not_empty: true }) for_each: params.areas # optional: one copy per list item body: { … } # the admin API request body| Kind | Endpoint | Lands |
|---|---|---|
settings |
PUT /v1/admin/workspaces/{id} |
Changed |
team |
POST /v1/admin/teams |
Empty: the customer adds people |
group |
POST /v1/admin/groups |
Ready |
segment |
POST /v1/admin/segments |
Ready (a segment sends nothing) |
form |
POST /v1/admin/forms, then PATCH /v1/admin/forms/{id} with draft_state |
Draft, not published |
form_fields |
PATCH /v1/admin/forms/{id}: adds fields to the target form’s draft |
Draft change |
template |
POST /v1/admin/templates |
Ready (sends nothing by itself) |
saved_question |
POST /v1/admin/saved-questions |
Ready |
saved_funnel |
POST /v1/admin/saved-funnels |
Ready |
dashboard |
POST /v1/admin/dashboards, then one POST …/cards per entry in cards |
Ready |
report |
POST /v1/admin/reports |
Off (enabled: false) |
workflow |
POST /v1/admin/workflows |
Off (enabled: false) |
Bodies are the API’s own fields, with two conveniences:
-
Nested JSON stays nested. The spec writes every nested field as YAML. The planner sends each one the way its endpoint wants it:
Sent as a JSON string Sent as an object segment definition; workflowtrigger_config,steps,goal,exit_conditions; templatemetadata,variablesreport spec,recipients,channel_policy; saved questionquery_ast,viz_settings; saved funneldefinition; formdraft_state,settings,verification_settings; dashboard card fieldsReads (
GET) return all of these as strings, so an export parses them first. -
Ids are references. A team in
assignee_refs, a template insend_message, a question in a report section: every id is a{{ ref.* }}, and the validator rejects a literal id. The planner fills each one with the type the API expects: a number everywhere exceptchannel_config.single.template_id, which is a string.
Placeholders also work in keys, for a set_trait whose trait name is a parameter:
traits: { "{{ params.appointment_trait }}": "{{ properties.slot_start_ts }}" }.
WhatsApp templates set provider_template_id to the Meta template name (lowercase letters,
digits and _), write the body with numbered slots {{1}}, {{2}} in order, and map each slot
in variables to Liquid: variables: { "1": "{{ traits.first_name }}" }. Meta must approve the
template before a follow-up can open a conversation with it, which is the customer’s step.
Fields the API ignores stay out of specs: dry_run on workflows (set separately) and
refresh_policy on segments. SMS metadata.sms.append_stop does nothing either, so SMS
templates end their body with their own opt-out line (“Reply STOP to opt out.”).
Two more constructs work anywhere inside a body:
whenon a list entry. A workflow step or report section withwhen: { param: send_sms, equals: true }is dropped when the answer doesn’t match. The planner removes thewhenkey before calling the API.$eachexpands a list parameter.{ $each: params.list_columns, $value: { field: "properties.{{ item }}" } }becomes one$valueper item, with{{ item }}(or{{ item.key }}for a list of objects) filled in.
Inside a for_each object, a reference to another for_each object over the same list means
the copy for the same item: in one routing follow-up per area, {{ ref.team_area.id }} is that
area’s team.
form_fields takes a target that names the form to change, either a parameter
(target: { param: intake_forms }, one slug or a list of slugs) or a reference (target: { ref: form_card }),
and insert_after (a field key). Publishing is always a separate, explicit step.
What the planner has to know
Section titled “What the planner has to know”- Order. Apply a
settingstime zone first: schedules and reports copy the workspace’s zone when they’re saved, and a later change doesn’t reach them. Then objects in file order. - No duplicates on a re-run. Team names are unique per workspace (a second create returns 409); everything else allows duplicate names. Match existing objects by name and reuse them.
- Drafts aren’t live. A workflow only reaches the data plane once it’s on, and a template
only when its status is
active, so a playbook’s templates are createdactiveand its workflows are tested after they’re switched on. - Gates. Switching a workflow on and publishing a form need the user’s phone to be verified, and the workflow’s steps pass the validator. Reports need the Reports feature on the workspace’s plane.
- Assignees are teams. Tasks and reports go to teams (or single collaborators); a group can’t receive them. A task whose team is empty is skipped silently, so the customer staffs teams before switching anything on.
- Flow. Steps run in order and there is no end step. Playbooks use
filtersteps (a non-match ends the run quietly) andexit_conditions(the run stops when an event arrives) rather than branches. A step’seventcondition only looks at the event that started the run, so “stop once they’ve paid” is an exit condition, not a filter. - Dates. Birthday-style rules (
day_of_year_eq,anniversary_within) belong in a segment with its owntimezone; step conditions read them in UTC.
Every playbook ships the request a customer would type, used as a live copilot eval
(data-plane/internal/orchestrator/copilot/testdata/evals, live block).
eval: name: 62_edtech_admissions_funnel mode: task # task (build) | chat (question) vertical: "" # "church" runs it as a church workspace prompt: … expect_artifacts: [segment, workflow, message_template] forbid_terms: [discount]It passes when every piece is a valid draft and everything lands off.
People data
Section titled “People data”A spec holds structure and parameters only. It never holds a name, email, phone number,
address, id, token or the customer’s workspace name. Examples use example.com and invented
organisations. The validator scans every string for email addresses, phone-like numbers,
ws_/usr_ ids, UUIDs, tokens and links outside origamy.io, and fails the spec if it finds one.
Checking a spec
Section titled “Checking a spec”npm run playbooks:checkIt validates every spec, checks each has a docs page, and rewrites public/playbooks/index.json.
