Build your first pack

This tutorial builds Triage Lite: a slash command that investigates, reports, and waits for a human before changing anything. It combines a workflow, an Open Agent Rule, and a prompt inject. Every example below is tested by the engine.

You need the app installed and the pw CLI on your path (setup).

1. The manifest

Make a directory called triage-lite and put this in it. The manifest is what turns a directory into a pack:

extension.yaml
manifest_version: 1
id: acme/triage-lite
name: Triage Lite
version: 1.0.0
description: A minimal pack — one workflow, one rule, one inject template.
compatibility:
  extension_api: "^1.0.0"
dependencies:
  painted-wolf/platform:
    version: "^1.0.0"

compatibility.extension_api is the host-contract gate — a SemVer range the host must satisfy. A mismatched range leaves the pack installed but non-contributing (extension_api_incompatible). dependencies names packs that must be contributing (with their own version ranges); every pack depends on painted-wolf/platform. Prompt wording in stock packs updates with the app and is not a separate version field — see Extension packs.

2. The workflow

A workflow is an ordered list of phases, each declaring what has to be true before the run moves on. Ours has two: investigate, then done.

workflows/triage-lite/workflow.yaml
id: triage-lite
version: 1.0.0
name: Triage Lite
description: Look at the problem, write up what you found, and stop for a human.
trigger: /triage-lite
initial_posture: spec
injects:
  - on: phase.entered
    selector:
      workflow: triage-lite
      phase: investigate
    effect: inform
    render: triage-lite-plan
    tier: workflow
phases:
  - id: investigate
    activity_label: Investigating the problem
    coordinator_surface: orchestrate_plan
    surface_template: agents/coordinator-surface-orchestrate.md
    mode_refs: [plan-research]
    complete_when: gates_satisfied
    gates: [human_approval]
    advance:
      when_gate_met: auto
    human_approval: {}
    next: done
  - id: done
    activity_label: Done
    coordinator_surface: await_user
    surface_template: agents/coordinator-surface-plan.md
    mode_refs: [await-user-input]
    terminal: true
    complete_when: orchestration_complete

Three things matter:

  • Every phase names its surface. coordinator_surface, surface_template, and mode_refs are required, including on the terminal phase. The surface is the tool ceiling.
  • The gate is a human. gates: [human_approval] renders an Approve button. Chat never approves. advance.when_gate_met: auto advances as soon as the gate is met.
  • The inject is the pack’s own guidance. The injects: entry fires when the phase is entered and renders a template from this pack’s guidance/ directory.

Every field the loader understands is in the manifest reference.

3. The rule

A workflow says what the run does. A rule says what it may never do. This one blocks writes for as long as the run holds the spec posture:

policy/TRIAGE_LITE_WRITE_FORBIDDEN.yaml
oar: '1.0'
id: TRIAGE_LITE_WRITE_FORBIDDEN
kind: policy
anchor: tool.pre_invoke
selector:
  tool:
    - write
    - edit
requires:
  profiles:
    - session
    - tool
when: session_posture == "spec"
effect: block
enforcement: enforce
on_error: fail_closed
copy:
  title: Triage Lite is read-only
  what: A write tool was called during triage.
  cause: The triage-lite workflow reports findings; it does not change files.
  why: Triage runs in the spec posture, where the project tree stays untouched.
  fix: Record the finding in your write-up, then leave triage before editing.
  instead: Branch on Code TRIAGE_LITE_WRITE_FORBIDDEN and report instead of writing
x-lycaon-emit: rule:triage_lite
x-lycaon-message: Triage Lite is a read-only workflow.
x-lycaon-scenarios:
  - id: default
    vars:
      tool: write
      path: src/main.go
      profile: coordinator
    expect_contains:
      - 'Rejected:'
      - 'Code: TRIAGE_LITE_WRITE_FORBIDDEN'
      - Branch

The filename is the rule id and the Code: an agent receives when it fires. when: is CEL over the closed fact catalogue; unknown facts fail at load. copy: supplies the rejection guidance, and x-lycaon-scenarios: tests it.

4. Prove the rule both ways

A rule that only fires is half-tested. Put conformance fixtures in a conformance/ directory beside policy/ — given these facts, expect this decision:

conformance/write-blocked.json
{
  "name": "triage-lite-write-blocked",
  "rule": {
    "oar": "1.0",
    "id": "TRIAGE_LITE_WRITE_FORBIDDEN",
    "kind": "policy",
    "anchor": "tool.pre_invoke",
    "selector": { "tool": ["write", "edit"] },
    "requires": { "profiles": ["session", "tool"] },
    "when": "session_posture == \"spec\"",
    "effect": "block",
    "enforcement": "enforce",
    "on_error": "fail_closed",
    "x-lycaon-emit": "rule:triage_lite"
  },
  "input": {
    "anchor": "tool.pre_invoke",
    "facts": { "tool": "write", "session_posture": "spec" }
  },
  "expected": { "decision": "block", "code": "TRIAGE_LITE_WRITE_FORBIDDEN" }
}
conformance/write-allowed-outside-spec.json
{
  "name": "triage-lite-write-allowed-outside-spec",
  "rule": {
    "oar": "1.0",
    "id": "TRIAGE_LITE_WRITE_FORBIDDEN",
    "kind": "policy",
    "anchor": "tool.pre_invoke",
    "selector": { "tool": ["write", "edit"] },
    "requires": { "profiles": ["session", "tool"] },
    "when": "session_posture == \"spec\"",
    "effect": "block",
    "enforcement": "enforce",
    "on_error": "fail_closed",
    "x-lycaon-emit": "rule:triage_lite"
  },
  "input": {
    "anchor": "tool.pre_invoke",
    "facts": { "tool": "write", "session_posture": "build" }
  },
  "expected": { "decision": "allow" }
}

Same rule, same tool, different posture — one blocks, one doesn’t. The rule document itself is a portable Open Agent Rules document; the fixture format is the engine’s own runner format.

5. The inject template

Finally, the guidance the coordinator sees when the investigate phase opens. Templates are pongo2 — Django-style tags, markdown body:

guidance/triage-lite-plan.md
Triage Lite is running. Report what you find; do not change files.

{% if workflow_phase %}Phase: {{ workflow_phase }}.{% endif %}
Finish with a short write-up, then ask for the human's sign-off.

The render: triage-lite-plan in the manifest resolves to this file: the host adds the directory and the .md for you.

6. Validate and install

From the directory that contains triage-lite/:

pw extensions install path:./triage-lite --device
pw extensions validate
pw workflow validate triage-lite/workflows/triage-lite/workflow.yaml
pw rules test triage-lite/policy

Expect a contributing pack, zero workflow errors, and one rule with two passing scenarios.

install path: links the folder rather than copying it, so the pack you keep editing is the pack the app loads. Drop --device to install for the current project instead of the whole machine.

If a fixture disagrees, rules test exits 1 with scenario_mismatch. The author loop is write, validate, read the code, and fix.

7. See it in the app

Open Settings → Extensions. acme/triage-lite is listed with its three units. Type /triage-lite in a session and the workflow starts; the investigate phase opens with your inject in the coordinator’s context, and the run stops at the approval button.

Iterating: edit the folder, hit Reload from disk, start a new turn. Installed-from-folder packs re-read their source on Reload, so there is no reinstall step while you work.

8. Read the prompt you actually shipped

A template that fails to parse is an error. A template with a typo’d variable is not — it renders empty, and the only way to see that is to look:

pw prompts render --agent implementer --project . --check

That renders a full persona through the same layers a live session uses, and --check adds the heading contract and the byte budget on top. Drop --agent and pass a template ref instead to render one file, with --var k=v for context.

Where to go next

You wantRead
Surfaces, gates, and the generated manifest field listCustom workflows
The fact catalogue and anchor list your rules select onOpen Agent Rules
Overlay one prompt file and add a phase injectPrompts
Shipping this to a team, and the provide − disabled ⊕ own algebraExtension packs
Diagnostic codes, CLI reference, stability promisesReference