Audience: Developers writing their first ClosedLoopOS SOP. Goal: By the end of this guide you will have authored, validated, and run a custom SOP. Related: SOP Schema Reference · CLI Reference
ClosedLoopOS uses a two-layer system for SOP storage:
| Layer | Location | Purpose |
|-------|----------|---------|
| Authoring | docs/sops/{sopId}.yaml | Current working version — edit and validate here |
| Versioned history | sops/{sopId}/{version}.yaml | Every published version — git-tracked |
| Registry (runtime) | Thin server Drizzle tables | sop_registry + sop_versions — API backing store |
To register a SOP with the thin server (so it appears in clos sop list via the server):
clos sop push docs/sops/my-sop.yaml
This calls POST /api/companies/:cid/sops which stores the SOP in Drizzle.
The clos sop sync-export command maintains a versioned history directory:
# Export all published versions from the DB to sops/{sopId}/{version}.yaml
clos sop sync-export --to-yaml
# Import versioned YAML files from git HEAD into Drizzle
clos sop sync-export --from-git
Use --dry-run to preview without writing. Use --json for machine-readable output.
This section is the fast path. Follow it end-to-end before reading the reference sections below.
Prerequisites: clos CLI installed, project initialised (clos init done inside a project directory). See Quickstart if not.
Note:
clos init --dir <path>works with both relative and absolute paths. Useclos init --dir /tmp/my-project --skip-promptsto create a project at an absolute path directly.
cat > docs/sops/hello-world.yaml << 'EOF'
schema_version: "1"
sop:
id: hello-world
version: 0.1.0
name: Hello World
description: A minimal one-step SOP for learning.
tags: [example]
triggers:
- type: manual
inputs:
- name: message
type: string
description: Message to log.
required: false
default: "Hello from ClosedLoopOS!"
steps:
- id: greet
name: Log greeting
agent:
role: engineer
action: |
Log the following message and mark this step done: {{ inputs.message }}
EOF
clos sop validate hello-world
# ✓ hello-world — valid
clos sop run hello-world --dry-run
# Dry run: Hello World (hello-world v0.1.0)
#
# Steps:
# 1. [engineer] Log greeting
No API connection needed for either command.
export CLOSEDLOOP_API_URL=http://localhost:3100
export CLOSEDLOOP_COMPANY_ID=<your-company-id>
export CLOSEDLOOP_API_KEY=<your-api-key>
Find your credentials in the dashboard → Settings.
Then map the agent roles defined in your SOP to actual agent IDs:
clos agent-roles init
This queries the API and writes .clos/agent-roles.json in the project root. Re-run after team roster changes.
Thin Server users:
clos agent-roles initrequiresGET /api/companies/:id/agentswhich the Thin Server does not implement. Useclos agent-roles set <role> <agent-id>instead (e.g.clos agent-roles set engineer 00000000-0000-0000-0000-000000000002). The tutorial example above usesrole: engineerwhich matches the Thin Server's bootstrapped agent — no manual mapping is needed for this guide.
Note:
clos sop pushrequires your ClosedLoopOS backend to have SOP Registry endpoints installed. If you are using a backend that does not yet support the registry, skip this step — you can still run SOPs directly from local files (see Step 5 with a local file path instead of a registry ID).
clos sop push docs/sops/hello-world.yaml
# Registered hello-world version X
The version number is assigned by the server registry (not the
version:field in the YAML). Re-pushing increments the server-side version automatically.
clos sop run docs/sops/hello-world.yaml
If you successfully pushed to the registry in Step 4, you can also use the registry ID form:
clos sop run hello-world.
Expected output:
Run ID: <uuid>
Issue: CLO-XXX
SOP: Hello World (hello-world v0.1.0)
Steps: 1
CLO-XXX [engineer] Log greeting
Dashboard: http://localhost:3100/dashboard
Next: Check the dashboard above, or run 'clos sop runs' to list recent runs.
The
Run IDfield shows the run UUID. Useclos sop runsto list recent runs from local run records (works on both backends). For thin server users, useclos sop status <instance-id>to check a specific run — it reads from.clos/sop-runs/<instance-id>.jsonlocally.
After a fire-and-forget run (clos sop run docs/sops/hello-world.yaml), check the dashboard or list recent runs:
clos sop runs
Expected output:
Runs (N most recent)
Run ID SOP Status Started
─────────────────────────────────────────────
abc123 hello-world done 30s ago
For live tailing a specific run, use clos run status <run-id> --follow with the Run ID from the issue created in Step 5. Alternatively, run with --watch to stream step completions automatically:
clos sop run hello-world --watch
That's it. You have authored, pushed, and executed your first SOP. The reference sections below explain every field in detail.
A Standard Operating Procedure (SOP) is a YAML file that describes a repeatable agent workflow. Each SOP defines:
SOPs live in docs/sops/<sop-id>.yaml. The filename must match the sop.id field.
Every SOP file has this skeleton:
schema_version: "1"
sop:
id: my-sop # kebab-case, matches filename
version: 0.1.0
name: My SOP
description: |
One-to-three sentences: what this SOP does and when to use it.
tags: [example]
triggers: [...] # at least one required
inputs: [...] # optional; define what callers must supply
steps: [...] # the work — a DAG of agent tasks
outputs: [...] # optional; surface step results to the caller
on_complete: [...] # optional; post-run notifications or side effects
Triggers determine when a SOP runs. You must define at least one.
The simplest trigger — a human or agent calls the SOP by name.
triggers:
- type: manual
Run automatically on a cron schedule (UTC).
triggers:
- type: schedule
cron: "0 9 * * 1" # Every Monday at 09:00 UTC
timezone: "UTC"
Fire when a ClosedLoopOS platform event matches a filter predicate.
triggers:
- type: event
event: issue.status_changed
filter:
status: done
labelId: "some-label-id"
Supported event types: issue.status_changed, issue.created, agent.hired, approval.resolved.
Receive an HTTP POST from an external system. The SOP runner generates the endpoint URL at runtime.
triggers:
- type: webhook
You can list several triggers — they are OR'd. The SOP runs when any one fires.
triggers:
- type: manual
- type: schedule
cron: "0 9 * * 1"
Inputs are values the caller supplies when starting a SOP. Steps reference them with {{ inputs.name }}.
inputs:
- name: issueId
type: string
description: The ClosedLoopOS issue ID to process.
required: true
- name: dryRun
type: boolean
description: When true, skip side effects.
required: false
default: false
| Type | Example value |
|------|--------------|
| string | "onboard-new-agent" |
| number | 50 |
| boolean | true |
| object | { key: value } |
| array | ["a", "b"] |
required: true — must be supplied at trigger time. No default needed.required: false (default) — caller may omit it. Provide a default to avoid null.Steps are the heart of a SOP. They form a DAG (directed acyclic graph):
depends_on start immediately (they can run in parallel).depends_on set also run in parallel.depends_on steps have completed successfully.steps:
- id: greet
name: Send welcome message
agent:
role: founding-engineer
action: |
Post a welcome comment on issue {{ inputs.issueId }}.
Every step requires: id, name, agent.role, and action.
| Field | Required | Description |
|-------|----------|-------------|
| id | yes | Unique kebab-case within this SOP |
| name | yes | Human-readable label shown in the run log |
| agent.role | yes | Agent role assigned to execute this step |
| action | yes | Markdown instructions rendered to the agent |
| outputs | no | Values this step produces for downstream steps |
| depends_on | no | List of step ids to wait for before starting |
| on_error | no | fail | skip | retry | escalate (default: fail) |
| condition | no | Boolean expression; step is skipped when false |
Declare outputs so downstream steps can reference them with {{ steps.stepId.outputs.name }}.
- id: draft-plan
name: Draft implementation plan
agent:
role: founding-engineer
action: |
Write an implementation plan for {{ inputs.issueId }}.
Return the plan document revision ID as `planRevisionId`.
outputs:
- name: planRevisionId
type: string
description: Revision ID of the committed plan document.
A step that waits for a previous step:
steps:
- id: draft
name: Draft the document
agent:
role: pm
action: Write a product spec for {{ inputs.feature }}.
outputs:
- name: specText
type: string
- id: review
name: Review the document
agent:
role: ceo
action: |
Review the spec for {{ inputs.feature }}.
Spec text: {{ steps.draft.outputs.specText }}
depends_on: [draft]
Two steps with the same depends_on run concurrently:
steps:
- id: load-context
name: Load issue context
agent:
role: founding-engineer
action: Fetch the context for issue {{ inputs.issueId }}.
- id: write-tests
name: Write tests
agent:
role: qa-engineer
action: Write tests for issue {{ inputs.issueId }}.
depends_on: [load-context]
- id: write-code
name: Write implementation
agent:
role: founding-engineer
action: Implement issue {{ inputs.issueId }}.
depends_on: [load-context]
- id: review
name: Review and merge
agent:
role: founding-engineer
action: Merge the implementation for {{ inputs.issueId }}.
depends_on: [write-tests, write-code]
write-tests and write-code start together after load-context. review waits for both.
Use condition to skip a step based on runtime state. Expressions use {{ }} syntax:
- id: board-approval
name: Await board approval
agent:
role: ceo
action: Request board sign-off on issue {{ inputs.issueId }}.
depends_on: [review]
condition: "{{ inputs.requiresBoardApproval == true }}"
Every step has an on_error policy. Choose the right one for each step's criticality.
fail (default)Abort the entire SOP run immediately. Use for steps where partial completion is worse than no completion.
on_error: fail
skipMark the step as skipped and continue with downstream steps. Use for optional side effects (e.g., sending a notification).
on_error: skip
retryRetry the step up to max_attempts times with a delay between attempts. Use for transient failures (network calls, flaky external APIs).
on_error: retry
retry:
max_attempts: 3 # default: 3
delay_seconds: 60 # default: 60
escalateCreate a blocked ClosedLoopOS issue assigned to the agent's manager, then halt this step (not the whole SOP). Use for steps that require human judgment on failure.
on_error: escalate
Use {{ }} delimiters anywhere a string value appears in action, condition, inputs, or on_complete messages.
| Expression | Resolves to |
|------------|-------------|
| {{ inputs.foo }} | SOP-level input foo |
| {{ steps.stepId.outputs.bar }} | Output bar from step stepId |
| {{ env.TODAY }} | Runtime environment variable |
| {{ run.id }} | Current SOP run identifier |
Surface a step result to the caller after all steps complete:
outputs:
- name: commitSha
from: "{{ steps.commit.outputs.commitSha }}"
Fire side effects after all steps succeed:
on_complete:
- action: notify
agent:
role: ceo
message: |
SOP {{ sop.id }} completed. Run: {{ run.id }}.
- action: create_issue
title: "Follow-up: deploy {{ inputs.feature }}"
priority: high
assignee:
role: founding-engineer
onboard-new-agentHere is the complete docs/sops/onboard-new-agent.yaml annotated for learning:
schema_version: "1"
sop:
id: onboard-new-agent # Must match filename
version: 0.1.0
name: Onboard New Agent
description: |
End-to-end workflow for hiring, configuring, and activating a new agent.
Covers approval → hire → skill install → first heartbeat → readiness check.
tags: [hr, governance, onboarding]
triggers:
- type: manual # Kicked off by CEO or manager
- type: event
event: approval.resolved
filter:
status: approved
type: agent_hire
inputs:
- name: agentRole
type: string
description: Role/title for the new hire (e.g., "CTO")
required: true
- name: agentShortname
type: string
description: URL-safe short name (e.g., "cto")
required: true
- name: adapterType
type: string
required: true
default: claude_local
- name: reportsTo
type: string
required: true
default: ceo
- name: monthlyBudgetUsd
type: number
required: false
default: 50
steps:
# Step 1 — no depends_on, so it starts immediately
- id: draft-hire-request
name: Draft hire request
agent:
role: "{{ inputs.reportsTo }}" # Dynamic role binding from input
action: |
Draft a hire request for a new {{ inputs.agentRole }} agent (shortname: {{ inputs.agentShortname }}).
Use the ClosedLoopOS agent creation API to create a pending-approval agent record.
Post the approval link as a comment on this issue.
outputs:
- name: approvalId
type: string
# Step 2 — waits for step 1
- id: await-approval
name: Await board approval
agent:
role: "{{ inputs.reportsTo }}"
action: |
Monitor approval {{ steps.draft-hire-request.outputs.approvalId }}.
Proceed if approved; cancel the SOP if rejected.
depends_on: [draft-hire-request]
outputs:
- name: approved
type: boolean
- name: agentId
type: string
# Steps 3 and 4 share the same depends_on — they run in parallel
- id: install-skills
name: Install skills
agent:
role: "{{ inputs.reportsTo }}"
action: Install the `clos` skill for the new agent.
depends_on: [await-approval]
condition: "{{ steps.await-approval.outputs.approved == true }}"
outputs:
- name: skillsInstalled
type: array
- id: set-instructions-path
name: Configure AGENTS.md path
agent:
role: "{{ inputs.reportsTo }}"
action: |
Set the instructions path for agent {{ steps.await-approval.outputs.agentId }}
to agents/{{ inputs.agentShortname }}/AGENTS.md.
depends_on: [await-approval]
condition: "{{ steps.await-approval.outputs.approved == true }}"
# Step 5 — waits for both parallel steps above
- id: trigger-first-heartbeat
name: Trigger first heartbeat
agent:
role: "{{ inputs.reportsTo }}"
action: Trigger the new agent's first heartbeat and create an intro task.
depends_on: [install-skills, set-instructions-path]
outputs:
- name: firstRunId
type: string
# Step 6 — final verification
- id: readiness-check
name: Readiness verification
agent:
role: "{{ inputs.reportsTo }}"
action: |
Verify the new agent is fully operational:
1. First heartbeat completed (run {{ steps.trigger-first-heartbeat.outputs.firstRunId }})
2. MEMORY.md exists
3. Skills installed: {{ steps.install-skills.outputs.skillsInstalled }}
Post a readiness summary and mark done.
depends_on: [trigger-first-heartbeat]
on_error: escalate # Escalate to manager if readiness check fails
outputs:
- name: agentId
from: "{{ steps.await-approval.outputs.agentId }}"
- name: firstRunId
from: "{{ steps.trigger-first-heartbeat.outputs.firstRunId }}"
on_complete:
- action: notify
agent:
role: ceo
message: |
New agent onboarded: {{ inputs.agentRole }} ({{ inputs.agentShortname }}).
Agent ID: {{ steps.await-approval.outputs.agentId }}.
Before committing, validate your SOP against the schema:
clos sop validate my-sop
The validator checks:
schema_version is present and equals "1".sop.id matches the filename (strip .yaml).depends_on entries reference existing step id values.depends_on dependency chains exist.{{ steps.X.outputs.Y }} interpolations reference declared outputs.required: true inputs without a default are flagged as caller-supplied.retry config is only present on steps with on_error: retry.A passing run prints each check with ✓. Any failure prints ✗ with the line number and a fix suggestion.
Test execution without triggering real agent work:
clos sop run my-sop --dry-run --input agentRole=CTO --input agentShortname=cto
Dry run validates the DAG, resolves all input bindings, and prints the step execution order — without dispatching any agent tasks.
clos sop run my-sop --input agentRole=CTO --input agentShortname=cto
| Rule | Why |
|------|-----|
| sop.id must match filename | The runner looks up SOPs by ID derived from filename |
| Every depends_on entry must name an existing step id | Prevents phantom dependencies |
| No circular depends_on chains | Prevents the DAG from having cycles |
| Every {{ steps.X.outputs.Y }} must reference a declared output | Catches typos before runtime |
| retry only valid when on_error: retry | Prevents confusing no-op config |
| required: true inputs need no default (but may have one) | Caller must supply them explicitly |
Use clos sop debug to inspect variable resolution, template expansion, and depends_on structure before running.
clos sop debug docs/sops/my-sop.yaml
clos sop debug docs/sops/my-sop.yaml --input agentRole=CTO --input dryRun=true
What it shows:
depends_on list, and declared outputs{{ steps.X.outputs.Y }} in the SOP with its resolution statusExample output:
🔍 SOP Debug: My SOP (my-sop v0.1.0)
------------------------------------------------------------
Inputs (2 defined):
{{ inputs.agentRole }} = "CTO" [CLI override]
description: Role to assign
{{ inputs.dryRun }} = true [default: false]
Steps (2):
greet [ceo] "Send greeting"
action preview (first 200 chars):
Post a welcome message for the CTO.
templates found (1):
{{ inputs.agentRole }}
→ CTO
depends_on: []
outputs: [greetingText]
follow-up [ceo] "Send follow-up"
action preview (first 200 chars):
Post a follow-up for the CTO.
templates found (1):
{{ inputs.agentRole }}
→ CTO
depends_on: [greet]
outputs: [followUpText]
✓ No circular depends-on references
Step output references:
follow-up references:
{{ inputs.agentRole }}
→ CTO
If a SOP has circular depends_on references, clos sop validate will also report them as errors:
✗ my-sop — 1 error
[steps[].depends_on] circular dependency detected: step-a → step-b → step-a
ClosedLoopOS ships with ready-to-use SOP templates. Use clos sop init --template to bootstrap a new SOP from a template.
clos sop init --list-templates
Available SOP templates:
api-agent
customer-onboarding
incident-response
weekly-team-review
Use: clos sop init --template <name> [output-file]
clos sop init --template customer-onboarding docs/sops/customer-onboarding.yaml
# Creates docs/sops/customer-onboarding.yaml
clos sop init --template incident-response docs/sops/my-incident.yaml
# Creates docs/sops/my-incident.yaml
The id and name fields in the generated file are automatically derived from the output filename. Override with --force to overwrite an existing file.
| Template | Description |
|----------|-------------|
| api-agent | Onboard a new API/MCP agent — capability registration, MCP attachment, role assignment, responsiveness verification |
| customer-onboarding | B2B SaaS customer onboarding — account provisioning, welcome email, walkthrough scheduling, first-value confirmation |
| incident-response | Production incident runbook — acknowledge, triage, mitigate, customer comms, post-mortem scheduling |
| weekly-team-review | Monday cadence SOP — blocker triage, roadmap review, build health check, team summary |
Templates are also available as standalone YAML files in packages/cli/templates/ for reference or customisation.
| Mistake | Fix |
|---------|-----|
| Filename and sop.id don't match | Rename the file or update sop.id |
| depends_on references a step id that doesn't exist | Check for typos in step ids |
| Circular depends_on (A→B→A) | clos sop debug highlights cycles; remove the circular edge |
| Using {{ steps.X.outputs.Y }} for an output not declared on step X | Add the output declaration to step X |
| retry block on a step with on_error: fail | Either remove retry or change on_error: retry |
| Referencing {{ inputs.foo }} but no input named foo | Add the input to the top-level inputs list |
| All steps have depends_on → implicit serial pipeline | Remove unnecessary dependencies to unlock parallelism |
clos CLI Reference| Command | Description |
|---------|-------------|
| clos sop push <file> | Register/update a SOP in the server registry (Drizzle-backed) |
| clos sop sync-export --to-yaml | Export all published SOP versions to sops/{sopId}/{version}.yaml |
| clos sop sync-export --from-git | Import versioned YAML files from git HEAD into the registry |
Before marking your SOP ready for review, run through each item:
sop.id matches the filename (e.g. my-sop.yaml → id: my-sop)schema_version: "1" is presenttriggers:)id, name, agent.role, and actionaction text are declared in inputs:required: true (no default needed)default valuestring, number, boolean, object, array)depends_on value references an existing step iddepends_on between themaction text includes a clear done condition (e.g. "mark this step done when X"){{ steps.X.outputs.Y }} used in actions is declared in step X's outputs:{{ inputs.foo }} (plural), not {{ input.foo }}depends_on is always a list: depends_on: [step-id], not depends_on: step-idon_error policyretry blocks only appear on steps with on_error: retryon_error: fail (default); optional steps use on_error: skipclos sop validate <sop-id> passes with no errorsclos sop run <sop-id> --dry-run shows the expected step orderclos sop run <sop-id>) completes successfully with test inputsclos command reference