contenox
Browse docs/

Writing a chain by hand

Most agents never need this page. An agent is a Markdown declaration plus agents.toml, and contenox builds the chain behind it.

You are here because you need something a declaration cannot say: a branch, a different model per step, a recovery path, a point where a human is required. Then you write the state machine yourself, and the engine runs exactly what you wrote. This page walks you from a blank file to a working chain in five edits.

If you haven’t installed Contenox yet, do the Quickstart first. If you have not written an agent yet, do your first agent — it is the shorter road and probably the right one.


Workspaces

contenox init creates or refreshes two kinds of state:

A project-local workspace marker.contenox/workspace.id in the current directory. This is like .git/ — it marks this directory tree as a Contenox workspace. The engine walks up from your current directory looking for this marker to resolve which workspace you’re in.

Global runtime files~/.contenox/ stores everything that’s shared across workspaces: your agents, the envelopes they run under, the SQLite database, and the shipped chains under system/.

~/.contenox/                    ← global (shared across all workspaces)
├── local.db                    ← SQLite: backends, config, sessions, MCP registrations
├── agents.toml                 ← the knobs a declaration cannot reach
├── agents/                     ← your agents, one Markdown file each
├── hitl-policy-default.json    ← default HITL policy
├── hitl-policy-strict.json
├── hitl-policy-dev.json
├── hitl-policy-acp.json        ← editor (ACP) sessions
├── hitl-policy-acpx.json       ← headless / untrusted-driver (ACPX) sessions
└── system/                     ← the shipped chains: machinery, not yours to author
    ├── chain-planner-default.json  ← the default mission planner
    ├── chain-compact-default.json  ← history compaction
    └── chain-fim-default.json      ← editor autocomplete

./my-project/.contenox/         ← project-local workspace marker
└── workspace.id                ← unique workspace ID

To make any directory a workspace, run contenox init inside it. Workspace-scoped config (like default-chain and hitl-policy-name) is stored per-workspace in the SQLite database.

Taking ownership of a shipped chain is a copy. Files resolve by name — the workspace .contenox/ first, then ~/.contenox/, then ~/.contenox/system/. So copying one up a level makes it yours, and contenox init will not write over it or put a shipped copy back underneath.

Note: contenox init --local seeds the shipped chains and HITL policy presets into the workspace .contenox/ for you — the supported way to create workspace-local overrides without copying files by hand. contenox doctor lists which workspace copies are currently shadowing global ones.

What contenox init already gave you

Look in ~/.contenox/system/. Every chain file follows the chain-<role>-<variant>.json naming convention (the full grammar).

You don’t have to start there. You can write your own.

Chains live as files in ~/.contenox/ (and your workspace .contenox/). Name one chain-agent-<something>.json and it’s discovered as a fleet-dispatchable agent, fireable by its id (below); a session instead falls back to the configured default-chain, and ACP editor sessions run the workspace’s default chain the same way.


A minimal chain

Create .contenox/chain-agent-my-chain.json:

{
  "id": "my-chain",
  "tasks": [
    {
      "id": "answer",
      "handler": "chat_completion",
      "execute_config": {
        "model": "qwen3:8b",
        "provider": "ollama"
      },
      "transition": {
        "branches": [
          { "operator": "default", "goto": "end" }
        ]
      }
    }
  ]
}

Fire it:

contenox mission fire my-chain "what is the capital of France?" --wait

That’s the smallest working chain: one task, one default branch out. Now we’ll author behavior into it.


Edit 1 — Set a system prompt

Add system_instruction to the task. This is the agent’s persona for this chain — it lives in your file, not in vendor code.

{
  "id": "answer",
  "handler": "chat_completion",
  "system_instruction": "You are a terse senior engineer. One sentence answers. No preamble.",
  "execute_config": {
    "model": "qwen3:8b",
    "provider": "ollama"
  },
  "transition": { "branches": [{ "operator": "default", "goto": "end" }] }
}

The agent now answers in your voice, not the model’s default voice.


Edit 2 — Pick the model (and a fallback)

execute_config.model and execute_config.provider choose the backend. Use models[] and providers[] to author a fallback policy — the engine tries them in order. The execute_config block on the task becomes:

{
  "execute_config": {
    "models": ["qwen3:8b", "gpt-5-mini"],
    "providers": ["ollama", "openai"],
    "temperature": 0.2
  }
}

When the local model is unreachable, the chain falls back to OpenAI in the order you listed.

See the providers guide for backend setup.


Edit 3 — Branch on the output

A single task is a function call. A chain becomes interesting when it branches. Add a second task and route to it conditionally.

{
  "id": "my-chain",
  "tasks": [
    {
      "id": "classify",
      "handler": "route",
      "system_instruction": "Classify the message urgency. Respond 'urgent' or 'normal'.",
      "execute_config": { "model": "qwen3:8b", "provider": "ollama" },
      "transition": {
        "branches": [
          { "operator": "equals", "when": "urgent", "goto": "escalate" },
          { "operator": "equals", "when": "normal", "goto": "respond" },
          { "operator": "default", "goto": "respond" }
        ]
      }
    },
    {
      "id": "escalate",
      "handler": "chat_completion",
      "system_instruction": "This is urgent. Draft a one-line page to on-call.",
      "execute_config": { "model": "qwen3:8b", "provider": "ollama" },
      "transition": { "branches": [{ "operator": "default", "goto": "end" }] }
    },
    {
      "id": "respond",
      "handler": "chat_completion",
      "system_instruction": "Reply briefly and helpfully.",
      "execute_config": { "model": "qwen3:8b", "provider": "ollama" },
      "transition": { "branches": [{ "operator": "default", "goto": "end" }] }
    }
  ]
}

You authored the labels (urgent / normal) and the routing — the route set is just the branches you can read. See Transitions & branching for all available operators (equals, contains, starts_with, ends_with, edge_traversed_at_least, default).


Edit 4 — Constrain the tool policy

If the task uses tools, you author the policy. Allowlists, denylists, per-tool config — every constraint is a key.

{
  "execute_config": {
    "model": "qwen3:8b",
    "provider": "ollama",
    "tools": ["local_shell", "local_fs"],
    "tools_policies": {
      "local_shell": {
        "_allowed_commands": "ls,cat,grep,git",
        "_denied_commands": "sudo,rm,dd"
      },
      "local_fs": {
        "_max_read_bytes": "1048576"
      }
    }
  }
}

_allowed_commands and _denied_commands constrain what local_shell can run for this task, independent of any other chain.


Edit 5 — Add a retry policy

Transient failures shouldn’t kill a CI step. Author the retry behavior in the chain:

{
  "execute_config": {
    "model": "qwen3:8b",
    "provider": "ollama",
    "retry_policy": {
      "max_attempts": 4,
      "initial_backoff": "1s",
      "max_backoff": "30s",
      "jitter": 0.25,
      "rate_limit_min_wait": "10s"
    }
  }
}

Combine retry_policy with transition.on_failure to decide what happens when something goes wrong: retry, route to a recovery task, or escalate.


What you’ve written

  • A system prompt
  • Model selection with a fallback policy
  • Branching with a routing operator
  • A tool policy with allowlists
  • A retry policy with backoff and jitter

Switch providers by changing execute_config.provider and execute_config.model — the rest of the chain is unchanged.

Next

Esc to close