Skip to content

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.

spec_version: 1
id: base.first-contact-follow-up # <sector>.<slug>, matches the file path
title: First-contact follow-up with call tasks
sector: base # base | church | schools | retail | lending | saas
source:
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 generalises
vocabulary: { 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" list
objects: [ … ]
eval: { … }

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.

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 default

Types: 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 }}.

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 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; workflow trigger_config, steps, goal, exit_conditions; template metadata, variables report spec, recipients, channel_policy; saved question query_ast, viz_settings; saved funnel definition; form draft_state, settings, verification_settings; dashboard card fields

    Reads (GET) return all of these as strings, so an export parses them first.

  • Ids are references. A team in assignee_refs, a template in send_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 except channel_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:

  • when on a list entry. A workflow step or report section with when: { param: send_sms, equals: true } is dropped when the answer doesn’t match. The planner removes the when key before calling the API.
  • $each expands a list parameter. { $each: params.list_columns, $value: { field: "properties.{{ item }}" } } becomes one $value per 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.

  • Order. Apply a settings time 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 created active and 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 filter steps (a non-match ends the run quietly) and exit_conditions (the run stops when an event arrives) rather than branches. A step’s event condition 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 own timezone; 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.

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.

Terminal window
npm run playbooks:check

It validates every spec, checks each has a docs page, and rewrites public/playbooks/index.json.