OpenSOP Agent Implementation Guide
Audience: an AI agent being asked to author, port, or extend business processes on OpenSOP. You have been given this document plus the target repository. Everything you need to produce a valid .sop.yaml and wire it up is here.
Companion docs: SPEC.md (formal format spec), API.md (runtime endpoints), processes/examples/ (working references).
1. What you are being asked to do
Your work falls into one of three shapes. Identify which one, then jump to the matching playbook at the end of this doc.
| Task shape | Playbook |
|---|---|
| "Here's a process description — write it as OpenSOP YAML" | Playbook A: Author from scratch |
| "Here's our existing Rails state machine / controller flow — port it to OpenSOP" | Playbook B: Port an existing workflow |
| "Integrate this new provider (compliance, KYC, payments) as a step" | Playbook C: Add a webhook integration |
| "Let this SaaS tool (Cal.com, Stripe, etc.) start a process directly" | Playbook D: Wire a third-party trigger |
Always:
- Read the user's description twice. List the concrete artifacts at each boundary: what comes in, what goes out, what humans decide, what systems integrate, what happens if a step fails.
- Match each boundary to a step type (§3 below). Don't invent step types. The canonical eight are
form,automated,judgment,approval,webhook,subprocess,notification,wait. - Write the YAML in one pass, then self-check against §5 before claiming you're done.
2. The OpenSOP 0.1 format in one page
Every process is a single YAML file with this top-level shape:
opensop: "0.1" # format version — always "0.1"
process:
name: snake-or-kebab-case # must match /^[a-z0-9][a-z0-9_-]*$/
version: "1.0" # QUOTE IT — unquoted 1.0 is parsed as a float
description: "One-sentence description of what this process does."
owner: "team-name" # free-form; shown in UI
tags: [tag1, tag2] # free-form; used for filtering
trigger:
type: api # only "api" is implemented in v0.1
inputs: # what the caller must / may provide to POST /start
- name: company_name
type: string
required: true
- name: country
type: enum
values: [US, MX, CA]
required: true
- name: deal_id
type: string
description: "External CRM reference"
# optional — no `required: true`
outputs: # what the process emits on completion
- name: account_id
type: string
from: steps.create-account.outputs.account_id
- name: rejection_reason
type: string
from: steps.review.outputs.rejection_reason
required_if: "status == 'rejected'" # condition expression
steps: # ordered list — execution follows this order
- id: collect-info # must match /^[a-z0-9][a-z0-9_-]*$/, unique within the process
name: "Collect business information"
type: form
description: "Gather company details"
inputs: # values resolved at step start from process inputs / earlier steps
- name: company_name
from: process.inputs.company_name
outputs: # keys the step must produce
- name: business_record
type: object
schema:
legal_name: string
rfc: string
condition: "..." # optional — if evaluates false, step is skipped
timeout: 7d # optional — parsed but not enforced in v0.1
on_timeout: notify-and-wait
- id: verify-docs
type: automated
run: ./examples/steps/verify-documents.rb # path relative to processes/
inputs:
- name: business_record
from: steps.collect-info.outputs.business_record
outputs:
- name: verification_result
type: enum
values: [complete, incomplete]
retry:
max: 3
backoff: exponential # parsed but auto-retry not implemented yet
# ... more steps ...
on_error: # optional
notify:
channel: slack
target: "#ops-alerts"
sla:
target: 72h
warning: 48h
Field reference types
Every inputs[*] and outputs[*] entry has name and type. Allowed type values:
| Type | Notes |
|---|---|
string |
Also accepts format: email or format: uri for light validation |
number |
Integer or float |
boolean |
true / false |
enum |
Must be accompanied by values: [a, b, c] |
object |
Accepts a schema: hash mapping keys to type strings |
string[] / number[] / object[] |
Arrays of primitives or objects |
Fields can be marked required: true. On outputs, required_if: "<condition>" makes them conditionally required.
The from: resolver
Any step input (and process output) can reference values produced earlier. Resolution syntax:
| Reference | Resolves to |
|---|---|
process.inputs.<name> |
A value the caller passed to /start |
steps.<step-id>.outputs.<name> |
A value produced by a prior step |
instance.id / instance.started_at / instance.metadata.<key> |
Runtime instance attributes |
env.<VAR_NAME> |
A process environment variable |
If a from: cannot resolve, the engine raises unresolved_reference (422). This is a YAML bug, not a user error.
The condition expression language
condition:, required_if:, and similar fields accept a tiny expression language. No eval. Supports:
- Operators:
== != > >= < <= && || ! - Parentheses
- Literals: numbers, quoted strings (
'foo'or"foo"),true,false,null - References: same syntax as
from:—steps.x.outputs.y,process.inputs.z, etc. - Bare identifiers (for
required_ifonly) — resolve against the step's own outputs:required_if: "status == 'rejected'"looks upstatusin the current step's outputs.
Examples:
condition: "steps.review.outputs.decision == 'approve'"
condition: "process.inputs.country == 'US' && steps.verify.outputs.score > 0.8"
required_if: "verification_result != 'complete'"
3. Step-type reference
Pick one per boundary. If you find yourself reaching for a type that isn't listed, stop — you're probably over-engineering.
form — collect data from a human or agent
The engine pauses the instance at sub_state=waiting_for_input. A caller (human via UI, agent via API) submits the step's declared outputs via POST /sop/:name/:id/steps/:step_id/submit.
- id: collect-business-info
type: form
name: "Collect business information"
outputs:
- name: legal_name
type: string
required: true
- name: annual_revenue
type: number
Use for: every boundary where a human (or delegated agent) provides structured data. The KYB form, the "enter payment amount" field, the content brief.
automated — run a script
The engine spawns a script, pipes inputs as JSON on stdin, reads outputs as JSON from stdout. Works for any language with stdlib JSON support.
- id: score-lead
type: automated
run: ./examples/steps/score-lead.rb # path relative to processes/, not to this YAML
inputs:
- name: budget
from: steps.collect-context.outputs.budget
outputs:
- name: score
type: number
- name: qualified
type: boolean
The script must:
- Read JSON from stdin → a hash matching the declared
inputs - Write JSON to stdout → a hash matching the declared
outputs - Exit 0 on success, non-zero on failure (stderr captured as the error)
Use for: deterministic computation. Scoring, validation, string manipulation, lookups that don't need a human.
judgment — decision with confidence
The engine pauses at sub_state=escalated. A caller (LLM or human) submits a decision + confidence. If confidence < judgment.confidence_threshold, the engine can escalate (v0.1: just stays in escalated — submit manually).
- id: review-application
type: judgment
inputs:
- name: business_record
from: steps.collect-info.outputs.business_record
outputs:
- name: decision
type: enum
values: [approve, reject, request-more-info]
- name: rejection_reason
type: string
required_if: "decision == 'reject'"
judgment:
allow_agent: true
require_human_review: false
confidence_threshold: 0.9
escalation: manual
Use for: subjective decisions where the right answer isn't always deterministic. Loan approvals, content moderation, risk assessment.
approval — binary human gate
Simpler than judgment. Just waits for an approve/reject from a named role.
- id: approve-refund
type: approval
approval:
approver_role: "ops-lead"
reason_required_on_reject: true
outputs:
- name: approved
type: boolean
- name: reason
type: string
required_if: "approved == false"
Use for: policy gates that don't need discussion. Refund over $500, schema change approval, publishing a template.
webhook — outbound integration with callback
Registers a callback URL, pauses at sub_state=waiting_for_callback. The third party (compliance provider, KYC vendor, etc.) POSTs results to that URL; the engine merges them into the step's outputs and continues.
- id: submit-to-compliance
type: webhook
condition: "steps.review-application.outputs.decision == 'approve'"
inputs:
- name: business_record
from: steps.collect-info.outputs.business_record
outputs:
- name: entity_id
type: string
- name: compliance_status
type: enum
values: [pending, approved, rejected]
webhook:
method: POST
url: "${COMPLIANCE_PROVIDER_URL}/entities"
headers:
Authorization: "Bearer ${COMPLIANCE_API_KEY}"
body_template: ./examples/steps/compliance-payload.json
response_mode: callback
callback_path: /sop/webhooks/compliance
poll_timeout: 7d
How it works (v0.2): the engine fires the outbound HTTP call when the step executes, using the declared method, url, headers, and body. Two response modes:
response_mode: callback(default): fire the request, then wait. The engine auto-generates a unique callback URL (e.g.http://localhost:3000/sop/webhooks/a8f3...) and exposes it as the${callback_url}template variable. Reference it anywhere inurl,headers, orbody_templateso the provider knows where to post back. When the provider callsPOST /sop/webhooks/:callback_id, the engine merges the payload into the step'soutputs:and advances.response_mode: sync: fire the request and use the JSON response body directly as the step's outputs. No callback involved.
Variable interpolation (in url, headers, and the body template):
| Syntax | Resolves to |
|---|---|
${env.FOO} |
ENV["FOO"] — raises if unset |
${process.inputs.foo} |
instance-level input |
${callback_url} |
full callback URL (callback mode only) |
${foo.bar.baz} |
bare path → resolves against the step's resolved inputs |
Body: if body_template: references a file path, the file is loaded and interpolated. If omitted, the step's resolved inputs are sent as JSON with types preserved (numbers stay numbers, nested hashes stay hashes). String-interpolated templates always produce strings, so prefer omitting body_template: when the default shape works.
Configure the base URL for the callback by setting OPENSOP_BASE_URL (defaults to http://localhost:3000).
Use for: third-party integrations with async responses. Compliance providers, KYC vendors, payment processors, any API with "we'll call you back when we're done."
subprocess — start a child process
Starts another OpenSOP process as a child. The parent pauses at sub_state=waiting_for_subprocess until the child reaches a terminal state.
- id: run-compliance
type: subprocess
subprocess:
process: compliance-submission
version: "1.0"
inputs:
- name: business_record
from: steps.collect-info.outputs.business_record
outputs:
- name: compliance_status
from: subprocess.outputs.compliance_status
v0.1 limitation: stubbed — parent pauses, but no child is actually started. Submit manually to proceed.
Use for: reusing a process across multiple parent processes. A shared compliance submission, a shared payment routing process.
notification — fire-and-forget message
Sends an email, Slack message, SMS, etc. The engine does NOT wait for delivery confirmation.
- id: send-welcome
type: notification
inputs:
- name: contact_email
from: process.inputs.contact_email
outputs:
- name: email_sent
type: boolean
v0.1 implementation: stubbed — returns { "notified": true } instantly. No actual send. Wire up the real send in app/services/opensop/step_executors/notification.rb when you need it.
Use for: welcome emails, Slack notifications to ops channels, SMS alerts.
wait — pause
Pause the process for a duration or until a condition becomes true.
- id: cool-off
type: wait
wait:
seconds: 86400 # 24 hours
# OR
until: "steps.callback.outputs.received == true"
v0.1 limitation: seconds: returns immediately (no real timer). until: pauses but has no polling loop — submit manually to proceed.
Use for: cool-off periods, scheduled follow-ups, delayed escalations.
4. How the engine runs your YAML
Knowing this helps you author correctly the first time.
- Load.
Opensop::Registry.load_allrecursively globsprocesses/**/*.sop.yaml, validates each againstOpensop::DefinitionParser, upserts intosop_processes(name + version as primary key). - Start.
POST /sop/:name/startvalidates inputs, creates asop_instance, creates onesop_stepper step in the YAML (all inpending), then callsInstanceExecutor.advance!. - Advance. The executor walks steps in order. For each:
- Evaluate
condition:if present. If false → skip. - Resolve
inputs:via thefrom:resolver. - Dispatch to the step executor for the type (
Opensop::StepExecutors::<Type>). - Blocking types (
form,judgment,approval,webhook,subprocess,waitw/until:) → step staysactivewith asub_state. The engine stops advancing until the block clears. - Non-blocking types (
automated,notification,waitw/seconds:) → execute synchronously, markcompleted, continue to the next step.
- Evaluate
- Submit. When a human/agent/webhook submits outputs via the API, the engine validates them, marks the step
completed, and callsadvance!again to run the next batch of non-blocking steps. - Complete. When there are no more steps, the executor resolves the process-level
outputs:(viafrom:references) and marks the instancecompleted.
Key property: you can always pause-resume. Any step type that waits on humans or third parties is idempotent — submitting the same outputs twice is safe (the engine rejects the second submit with step_not_submittable).
5. Self-check before you're done
Run through this rubric before claiming a YAML is ready. These are the checks the engine itself runs — failing any of them means your YAML will be rejected at load.
Syntax
-
opensop: "0.1"at the top (with the quotes). -
version:is a quoted string, not a bare1.0(YAML parses that as a float). - Every
name:andid:matches^[a-z0-9][a-z0-9_-]*$. - No tabs in indentation (YAML requires spaces).
Structure
-
process.name,process.version,process.stepsare all present. -
steps:is an ordered list; stepids are unique within the process. - Every step has
idandtype. -
typeis one of:form,automated,judgment,approval,webhook,subprocess,notification,wait.
Inputs / outputs
- Every input referenced by a
from: process.inputs.Xexists inprocess.inputs. - Every
from: steps.Y.outputs.Zrefers to a step that appears earlier in the step list (forward references fail). - Every
automatedstep with arun:path has the script file at the resolved location (processes/<run-path>, becauserun:is relative toprocesses/, not the YAML). - Every
form,judgment,approval,webhookstep declaresoutputs:(the engine needs to know what shape to validate against). - Enum types have
values: [...]populated.
Conditions
- Every
condition:/required_if:expression uses only the allowed operators (§2). Noeval-like constructs. - Referenced fields in conditions exist (same rules as
from:above).
Ordering
- The first blocking step (typically a
form) is reachable from start — nocondition:blocks it all the time. - Any step referenced by a later step's
from:is upstream of that step. -
process.outputs[*].from:references steps that will exist at completion time.
If any box above is unchecked, fix before submitting. Load it with bin/rails opensop:load_processes to get the engine's exact error message.
6. Common mistakes
Real mistakes observed when agents (and humans) author processes. Each has a fast recognition heuristic.
"My run: path doesn't work"
Symptom: script not found at /path/processes/examples/steps/script.rb.
Cause: run: paths are resolved relative to processes/, not relative to the YAML file. A YAML at processes/examples/my-process.sop.yaml must reference its script as ./examples/steps/my-script.rb, not ./steps/my-script.rb.
See processes/README.md.
"My condition is always true / always false"
Symptom: steps skip that shouldn't, or run that shouldn't.
Cause: the condition references a field that doesn't exist in outputs — the expression evaluator treats missing identifiers as nil, which compares false to almost everything. Check the exact output key name on the upstream step.
"YAML is loading but /start returns invalid_inputs"
Symptom: caller gets 422 with "invalid_inputs" even though the body looks right.
Cause: the input field has required: true but the JSON body uses a different key name (case sensitivity, - vs _). The input name in YAML must match the JSON body key exactly.
"Webhook step never advances"
Symptom: step stuck at sub_state=waiting_for_callback forever.
Causes:
- The third party wasn't told about the callback URL. The engine doesn't auto-register — for v0.1, pass the URL to the provider out-of-band (their dashboard, a registration API, whatever).
- The third party posted to the wrong URL. Check
callback_pathin the step definition matches what the provider hit. - The provider's payload keys don't match the step's declared
outputs:(returns 422, keeps the step open). Check the provider's response schema.
"from: resolution error"
Symptom: unresolved_reference error at runtime.
Cause: the referenced step completed but its output hash has a different key than what from: expects. Your YAML declares outputs: [{name: account_id}] but the script wrote {accountId: "..."} — string keys, exact match required.
7. Playbooks
Execute one of these based on what you were asked for.
Playbook A: Author a process from a description
Input: a prose description of a workflow.
Steps:
-
List the boundaries. Read the description and list each point where data enters, leaves, or waits. Each boundary is a step. Typical boundaries:
- "User fills in X" →
form - "System validates/computes Y" →
automated - "Human decides approve/reject" →
approvalorjudgment - "External provider does Z" →
webhook - "Email/Slack notification" →
notification - "Wait N days / until callback" →
wait
- "User fills in X" →
-
List process inputs. What does the caller need to provide at
/start? These are values that cannot be computed — IDs, names, choices. -
List process outputs. What does the process emit at completion? These are almost always
from:references to terminal step outputs. -
Order the steps. Dependencies must flow forward. If step 3 needs data from step 1, put them in order; never reference a later step.
-
Write the YAML top-down:
opensop→process.name/version/description→inputs→outputs→steps→tags. -
Run the self-check in §5.
-
Save to
processes/<your-org>/<process-name>.sop.yaml(orprocesses/examples/...if it's a public example). -
Load and test:
bin/rails opensop:load_processes curl -X POST http://localhost:3000/sop/<process-name>/start \ -H "Content-Type: application/json" \ -d '{"inputs": {...}}' -
Iterate. If you get
invalid_inputsorunresolved_reference, fix the YAML and reload. The engine will reload definitions (upsert byname + version) — no need to restart the server.
Playbook B: Port an existing Rails state machine / workflow
Input: existing code that implements a workflow. Typically a Rails state machine (AASM, state_machines gem, or hand-rolled enum), a controller flow, or service objects.
Steps:
-
Map the states. List every state the entity can be in (e.g.
draft → submitted → reviewing → approved → provisioning → active). -
Map each state transition to a step type. Transitions triggered by:
- User action (form submit, click approve) →
form,approval, orjudgment - Async computation or DB work →
automated - External HTTP call with callback →
webhook - Email/SMS/Slack →
notification - Scheduled delay →
wait - Calling another workflow →
subprocess
- User action (form submit, click approve) →
-
Extract the state-machine's guards as
condition:expressions. If the current code hascan_approve? if documents_verified? && amount < 10_000, that becomescondition: "steps.verify.outputs.verification_result == 'complete' && process.inputs.amount < 10000"on the approval step. -
Extract side effects into step outputs. If the current code does
create_account!(customer)in theactivatingstate, the outputs of the correspondingautomatedstep are whatevercreate_account!returns —account_id,member_id, etc. -
Extract the final entity state as process outputs. If the workflow ends with a
customer.status = approvedand anaccount_id, the process outputs arestatusandaccount_id, bothfrom:references to the relevant step outputs. -
Wire side-effect scripts. For each
automatedstep, write the script that does the actual work. Move logic from the Rails service object into the script (or have the script invoke the service object via a Rails runner). -
Leave the old code alone initially. Run both systems in parallel. Start an OpenSOP instance for new records, keep the old state machine for in-flight ones. Cut over when stable.
-
Run the self-check in §5.
Worked example — porting a customer onboarding state machine:
Existing Rails code:
class Customer < ApplicationRecord
# State machine with: pending → verifying_docs → reviewing → submitting_to_compliance →
# creating_account → welcoming → active
#
# Transitions triggered by:
# - pending → verifying_docs: when CustomerController#submit_application runs
# - verifying_docs → reviewing: automatic after DocumentVerifier job
# - reviewing → submitting: when an ops lead clicks "approve"
# - submitting → creating_account: on Monex callback (webhook)
# - creating_account → welcoming: automatic after BankingService.create
# - welcoming → active: after WelcomeMailer sends
end
OpenSOP port:
opensop: "0.1"
process:
name: customer-onboarding
version: "1.0"
inputs:
- name: company_name
type: string
required: true
- name: contact_email
type: string
format: email
required: true
outputs:
- name: account_id
from: steps.create-account.outputs.account_id
- name: status
from: steps.review.outputs.decision
steps:
- id: collect-info
type: form
outputs:
- { name: business_record, type: object, schema: { legal_name: string, rfc: string } }
- id: verify-docs
type: automated
run: ./coba/steps/verify-documents.rb
inputs: [{ name: business_record, from: steps.collect-info.outputs.business_record }]
outputs: [{ name: verification_result, type: enum, values: [complete, incomplete] }]
- id: review
type: judgment
inputs: [{ name: business_record, from: steps.collect-info.outputs.business_record }]
outputs: [{ name: decision, type: enum, values: [approve, reject] }]
- id: submit-to-compliance
type: webhook
condition: "steps.review.outputs.decision == 'approve'"
webhook:
method: POST
url: "${COMPLIANCE_URL}/entities"
response_mode: callback
callback_path: /sop/webhooks/compliance
outputs:
- { name: entity_id, type: string }
- { name: compliance_status, type: enum, values: [approved, rejected] }
- id: create-account
type: automated
condition: "steps.submit-to-compliance.outputs.compliance_status == 'approved'"
run: ./coba/steps/create-account.rb
outputs: [{ name: account_id, type: string }]
- id: send-welcome
type: notification
inputs: [{ name: contact_email, from: process.inputs.contact_email }]
Note how each state transition maps cleanly to a step type, and each condition: encodes a guard that used to live in Ruby.
Playbook C: Add a new webhook integration (e.g. second compliance provider)
Input: an existing process that has a webhook step for one provider. You need to add a second provider (alternate compliance vendor, backup KYC, geo-specific routing) without disrupting the first.
Approach: add a new webhook step with a condition: that routes traffic. Both providers stay supported; the condition decides which runs.
Steps:
-
Identify the routing key. What determines which provider to use? Usually an input (
country,customer_tier) or a prior step output (risk_score). -
Add a second
webhookstep. Copy the existing one, change theid,url,callback_path, and add acondition:that inverts (or partitions) the first provider's condition. -
Normalize outputs. The two providers must produce the same
outputs:shape — otherwise downstream steps that depend onsteps.<provider>.outputs.entity_idbreak. Two options:- A (preferred): make the two webhook steps produce identical output schemas. Add
from:references in subsequent steps that tolerate either (a smallautomatedaggregator step:entity_id: steps.provider-a.outputs.entity_id || steps.provider-b.outputs.entity_id). - B: make the next step reference whichever provider ran, via a
condition:-gated transformation step.
- A (preferred): make the two webhook steps produce identical output schemas. Add
-
Add the new provider's callback URL to their dashboard/API the same way you did for the first.
-
Test both paths. Start an instance that triggers provider A, then one that triggers provider B. Verify both complete.
Worked example — adding a second compliance provider:
Before (single provider):
- id: submit-to-compliance
type: webhook
webhook:
method: POST
url: "${PROVIDER_A_URL}/entities"
callback_path: /sop/webhooks/provider-a
response_mode: callback
outputs:
- { name: entity_id, type: string }
- { name: compliance_status, type: enum, values: [approved, rejected] }
After (two providers, routed by country):
# US customers go to Provider A
- id: submit-to-provider-a
type: webhook
condition: "process.inputs.country == 'US'"
webhook:
method: POST
url: "${PROVIDER_A_URL}/entities"
callback_path: /sop/webhooks/provider-a
response_mode: callback
outputs:
- { name: entity_id, type: string }
- { name: compliance_status, type: enum, values: [approved, rejected] }
# MX / everyone else goes to Provider B
- id: submit-to-provider-b
type: webhook
condition: "process.inputs.country != 'US'"
webhook:
method: POST
url: "${PROVIDER_B_URL}/onboarding/submit"
callback_path: /sop/webhooks/provider-b
response_mode: callback
outputs:
- { name: entity_id, type: string } # note: same name as provider-a
- { name: compliance_status, type: enum, values: [approved, rejected] }
# Aggregator — picks whichever ran. Downstream steps reference THIS step.
- id: resolve-compliance
type: automated
run: ./coba/steps/resolve-compliance.rb
inputs:
- { name: provider_a_result, from: steps.submit-to-provider-a.outputs.entity_id }
- { name: provider_b_result, from: steps.submit-to-provider-b.outputs.entity_id }
- { name: provider_a_status, from: steps.submit-to-provider-a.outputs.compliance_status }
- { name: provider_b_status, from: steps.submit-to-provider-b.outputs.compliance_status }
outputs:
- { name: entity_id, type: string }
- { name: compliance_status, type: enum, values: [approved, rejected] }
resolve-compliance.rb is a trivial script that picks whichever input is non-null.
Key property: the existing create-account step doesn't need to change. It still references steps.submit-to-compliance.outputs.entity_id... wait, no — now it needs to reference steps.resolve-compliance.outputs.entity_id. That's the one downstream change. Make it, and the rest of the process continues to work.
Playbook D: Wire a third-party SaaS webhook as a process trigger
Input: a SaaS provider (Cal.com, Stripe, Typeform, HubSpot, DocuSign, etc.) that should kick off a process instance via its webhook delivery.
Why this exists: SaaS webhooks can't send X-SOP-Token (no way to inject custom headers from the provider side) and their payload shape is fixed to their API, not OpenSOP's /start envelope. The trigger: type: webhook block solves both problems: HMAC signature verification replaces token auth, and input_mapping reshapes the payload into the process's declared inputs.
Steps:
-
Identify the provider's signature scheme. Look up the webhook documentation for:
- Header name (e.g.
X-Cal-Signature-256,Stripe-Signature,X-Hub-Signature-256) - Algorithm (currently OpenSOP only supports
hmac-sha256) - Encoding (hex or base64)
- Prefix format, if any (e.g.
sha256=<hex>vs bare<hex>)
- Header name (e.g.
-
Declare the trigger in YAML:
process: name: consult-request version: "1.0" trigger: type: webhook auth: scheme: hmac-sha256 secret_env: PROVIDER_WEBHOOK_SECRET # pick a descriptive env var name header: X-Provider-Signature-256 encoding: hex # default; omit if hex prefix: "sha256=" # omit if provider sends bare hex input_mapping: # map provider's payload shape to the process's declared inputs primary_email: "${payload.attendees.0.email}" scheduled_at: "${payload.startTime}" external_id: "${payload.uid}" source: "cal.com" # literal -
Declare the process
inputsthat the mapping produces — these are the fields your steps will reference downstream:inputs: - { name: primary_email, type: string, format: email, required: true } - { name: scheduled_at, type: string, required: true } - { name: external_id, type: string, required: true } - { name: source, type: string } -
Set the secret env var in the deployment environment (both local
.envand prod config). Use a long random string (openssl rand -hex 32is standard). -
Configure the provider:
- Subscriber URL:
https://<your-opensop>/sop/triggers/<process-name> - Signature secret: paste the same value as the env var
- Event filter: if the provider sends multiple event types to the same endpoint (Cal.com sends both
BOOKING_CREATEDandBOOKING_CANCELLED), subscribe only to the event(s) that match your mapping — the engine logs mismatched events but returns 200, which looks green to the provider and can mask misconfigurations.
- Subscriber URL:
-
Test with a real event. Trigger the provider to fire (create a Cal.com booking, submit a Typeform, etc.). Watch for:
POST /sop/triggers/<name>returning 200 withstatus: started- A new instance appearing in
GET /sop/instances?process=<name> instance.inputsmatching what you expected from the mapping
-
Debug if something's off:
- HTTP 401
invalid_signature→ verify the secret matches on both sides, then check the signature format (did you setprefix: "sha256="when needed?) - HTTP 200
action: loggedwithreason: missing key "..."→ the provider's payload doesn't have the path your mapping expected. Log into the provider's webhook-delivery history, inspect the actual payload JSON, and adjust the mapping path. - HTTP 200
action: loggedwithreason: invalid_inputs→ mapping succeeded but the resulting inputs failed the process's declared schema (enum mismatch, missing required, etc.). Adjust either the mapping or the input declaration. - HTTP 500
trigger_misconfigured→ the secret env var isn't set on the running server. Check deployment config. grep MAPPING_REJECTED /var/log/opensop/production.log— all silent-logged failures have distinctive tags.
- HTTP 401
Worked example — Cal.com booking → consult-request process:
opensop: "0.1"
process:
name: consult-request
version: "1.0"
description: "Handle a consultation call booked via Cal.com"
owner: growth-team
trigger:
type: webhook
auth:
scheme: hmac-sha256
secret_env: CAL_WEBHOOK_SECRET
header: X-Cal-Signature-256
encoding: hex
prefix: "sha256="
input_mapping:
attendee_email: "${payload.attendees.0.email}"
attendee_name: "${payload.attendees.0.name}"
meeting_time: "${payload.startTime}"
booking_id: "${payload.uid}"
event_type: "${payload.eventType.title}"
source: "cal.com"
inputs:
- { name: attendee_email, type: string, format: email, required: true }
- { name: attendee_name, type: string, required: true }
- { name: meeting_time, type: string, required: true }
- { name: booking_id, type: string, required: true }
- { name: event_type, type: string }
- { name: source, type: string }
steps:
- id: create-crm-record
type: webhook
webhook:
method: POST
url: "${env.CRM_BASE_URL}/leads"
headers:
Authorization: "Bearer ${env.CRM_API_KEY}"
response_mode: sync
outputs:
- { name: crm_record_id, type: string }
- id: send-confirmation
type: notification
inputs:
- { name: to, from: process.inputs.attendee_email }
- { name: meeting_time, from: process.inputs.meeting_time }
outputs:
- { name: email_sent, type: boolean }
- id: record-outcome
type: form
name: "Record meeting outcome"
outputs:
- { name: attended, type: boolean, required: true }
- { name: qualified, type: enum, values: [yes, no, maybe], required: true }
- { name: notes, type: string }
tags: [growth, consult, cal.com]
Point Cal.com at https://<your-opensop>/sop/triggers/consult-request. Every booking becomes a consult-request instance. DenchClaw gets the lead via step 2. Ops records the outcome via step 3 after the call happens.
8. After the YAML: testing end-to-end
Once your YAML loads without error, exercise it:
# 1. Confirm it loaded
bin/rails opensop:load_processes
# → should list your process
# 2. Confirm it appears in discovery
curl http://localhost:3000/sop/ | jq '.processes[] | select(.name == "<your-name>")'
# 3. Start an instance with real inputs
curl -X POST http://localhost:3000/sop/<your-name>/start \
-H "Content-Type: application/json" \
-d '{"inputs": { "...": "..." }}'
# → capture the instance id
# 4. Submit each human-gated step in order
curl -X POST http://localhost:3000/sop/<your-name>/<id>/steps/<step-id>/submit \
-H "Content-Type: application/json" \
-d '{"outputs": { "...": "..." }}'
# 5. Verify final state
curl http://localhost:3000/sop/<your-name>/<id>
# → instance.state should be "completed"
# → instance.outputs should contain your declared process outputs
If any step fails, fetch the instance state — the error field on both the instance and the individual step will have the exact message.
9. When to ask for help
Stop and report back to your requester (don't keep trying) if:
- A step's behavior needs a step type that isn't in the eight listed. Don't invent types.
- A condition needs operators beyond the basic set (regex, arithmetic, function calls). Propose adding them to the engine rather than working around.
- The prose description is ambiguous about a boundary (is this a
formor anapproval?). One clarifying question is cheaper than a wrong guess. - The process requires behavior that cannot be expressed as a DAG of steps (loops, dynamic step count, recursive subprocess spawning). This is an architectural mismatch — flag it.
Appendix: Minimal working example
The smallest valid process — one step, one input, one output:
opensop: "0.1"
process:
name: hello-world
version: "1.0"
description: "Say hello"
trigger:
type: api
inputs:
- name: greeting_target
type: string
required: true
outputs:
- name: greeting
type: string
from: steps.say-hello.outputs.greeting
steps:
- id: say-hello
type: form
outputs:
- name: greeting
type: string
required: true
tags: [demo]
Save to processes/examples/hello-world.sop.yaml, load with bin/rails opensop:load_processes, start with curl -X POST http://localhost:3000/sop/hello-world/start -d '{"inputs":{"greeting_target":"world"}}' -H 'Content-Type: application/json'. The instance will pause at say-hello waiting for a submission.