ClosedLoopOS

status: authoritative

last_verified: 2026-03-25

owner: founding-engineer

applies_to: [founding-engineer]

SOP Authoring Guide

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


SOP Dual-Layer System

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 |

Registering SOPs with the Server

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.

Syncing Versioned History

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.


Quick Tutorial: Hello World in 5 Minutes

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. Use clos init --dir /tmp/my-project --skip-prompts to create a project at an absolute path directly.

1 — Create the SOP file

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

2 — Validate and preview

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.

3 — Connect to your ClosedLoopOS instance

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 init requires GET /api/companies/:id/agents which the Thin Server does not implement. Use clos agent-roles set <role> <agent-id> instead (e.g. clos agent-roles set engineer 00000000-0000-0000-0000-000000000002). The tutorial example above uses role: engineer which matches the Thin Server's bootstrapped agent — no manual mapping is needed for this guide.

4 — Push to the registry

Note: clos sop push requires 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.

5 — Trigger a live run

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 ID field shows the run UUID. Use clos sop runs to list recent runs from local run records (works on both backends). For thin server users, use clos sop status <instance-id> to check a specific run — it reads from .clos/sop-runs/<instance-id>.json locally.

6 — View run status

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.


What is a SOP?

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.


Anatomy of a SOP file

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

Part 1 — Triggers

Triggers determine when a SOP runs. You must define at least one.

Manual trigger

The simplest trigger — a human or agent calls the SOP by name.

triggers:
  - type: manual

Schedule trigger

Run automatically on a cron schedule (UTC).

triggers:
  - type: schedule
    cron: "0 9 * * 1"   # Every Monday at 09:00 UTC
    timezone: "UTC"

Event trigger

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.

Webhook trigger

Receive an HTTP POST from an external system. The SOP runner generates the endpoint URL at runtime.

triggers:
  - type: webhook

Multiple triggers

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"

Part 2 — Inputs

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

Input types

| Type | Example value | |------|--------------| | string | "onboard-new-agent" | | number | 50 | | boolean | true | | object | { key: value } | | array | ["a", "b"] |

Required vs optional


Part 3 — Steps

Steps are the heart of a SOP. They form a DAG (directed acyclic graph):

Minimal step

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.

Step fields reference

| 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 |

Step outputs

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.

Linear pipeline (depends_on)

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]

Parallel steps

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.

Conditional steps

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 }}"

Part 4 — Error handling

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

skip

Mark the step as skipped and continue with downstream steps. Use for optional side effects (e.g., sending a notification).

on_error: skip

retry

Retry 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

escalate

Create 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

Part 5 — Template interpolation

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 |


Part 6 — SOP outputs and on_complete

SOP-level outputs

Surface a step result to the caller after all steps complete:

outputs:
  - name: commitSha
    from: "{{ steps.commit.outputs.commitSha }}"

on_complete hooks

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

Part 7 — Worked example: onboard-new-agent

Here 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 }}.

Part 8 — Validation and running

Validate

Before committing, validate your SOP against the schema:

clos sop validate my-sop

The validator checks:

  1. schema_version is present and equals "1".
  2. sop.id matches the filename (strip .yaml).
  3. All depends_on entries reference existing step id values.
  4. No circular depends_on dependency chains exist.
  5. All {{ steps.X.outputs.Y }} interpolations reference declared outputs.
  6. required: true inputs without a default are flagged as caller-supplied.
  7. retry config is only present on steps with on_error: retry.
  8. YAML is syntactically valid (no parse errors).

A passing run prints each check with ✓. Any failure prints ✗ with the line number and a fix suggestion.

Dry run

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.

Live run

clos sop run my-sop --input agentRole=CTO --input agentShortname=cto

Validation rules cheat sheet

| 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 |


Part 9 — Debugging a SOP

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:

Example 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

Part 10 — Template Library

ClosedLoopOS ships with ready-to-use SOP templates. Use clos sop init --template to bootstrap a new SOP from a template.

List available templates

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]

Bootstrap from a template

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.

Available templates

| 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.


Common mistakes

| 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 |


SOP Author Checklist

Before marking your SOP ready for review, run through each item:

Structure

Inputs

Steps & DAG

Outputs & Interpolation

Error Handling

Testing


Next steps