Coco

Writing a contract

The full shape of a contract, what the compiler refuses, and how to test a rule before it costs anyone anything.

Writing a contract

Contracts live in ~/.coco/packs/<set>/, one file per contract. The mandate names which sets load, so a contract can sit in the directory unenforced until someone opts it in.

The shape

id: kyc.approve_customer
action: approve_customer
family: file.write            # or families: [egress, command]
description: >
  What this governs and which live state it reads. Write it for the person who
  will have to defend the rule, not for the engineer who installs it.

applies_when: '"04_Approvals" in tool.path'

pre_conditions:               # typed checks against a dotted path into the state
  - id: case_folder_readable
    type: equals
    path: case.present
    value: true
    on_fail: BLOCK
    reason: The case folder could not be read

constraints:                  # boolean rules in the safe subset
  - id: screening_is_done
    rule: case.screening_sanctions == true
    on_fail: BLOCK
    reason: Sanctions screening evidence is missing from the case folder
FieldWhat it does
idNames the contract. A receipt carries it, so keep it stable
actionWhat this governs, in your own vocabulary
family or familiesWhich action families this contract runs on. "*" runs on all
descriptionRequired. The compiler refuses a contract without one
applies_whenNarrows the contract to the calls worth checking
pre_conditionsTyped checks. How a contract refuses to judge on state it could not read
constraintsBoolean rules in the expression language

Families

A contract declares which actions it governs rather than which tools.

FamilyCovers
commandShell commands
file.readReads
file.writeWrites, edits, deletes
egressAnything that fetches or searches over the network
browserBrowser control
agentSpawning subagents, skills, messages between agents
taskCreating and stopping background work
mcpAny MCP tool
paymentPayment and wallet calls

Pre-conditions and constraints

A pre-condition is a typed comparison against a dotted path into the state. Use it when the question is whether the state is there at all.

TypePasses when
existsThe value is not missing
not_emptyA string, list or mapping has something in it
equals, not_equalsThe value matches, or does not
greater_than, less_than, gte, lteThe numeric comparison holds
in_list, not_in_listThe value is in the given list, or is not
matches_regex, not_matches_regexThe pattern is found, or is not

A constraint is a boolean rule. Use it when the question is what the state says.

constraints:
  - id: monthly_budget_holds
    rule: 'platform.monthly_spend_usd + tool.amount <= mandate.monthly_budget_usd'
    on_fail: BLOCK
    reason: >
      This payment would carry the wallet past its monthly budget, so it is
      stopped before the signer whatever the merchant or the amount

Rules take comparisons, boolean operators and arithmetic. No function calls, no lambdas, no comprehensions, nothing private. A rule an auditor cannot read out loud in a meeting is a rule nobody will defend when it blocks something.

The whole language fits in one table.

You can writeExamples
Comparisons== != < <= > >=
Membershipin, not in, against a list or a string
Booleanand or not
Arithmetic+ - * / %, so a rule can say spend + amount <= budget
Literalsnumbers, quoted strings, lists like [1, 2], and true, false, null
Stateany dotted path from the state reference

A name the state does not carry reads as nothing rather than raising, so the check fails instead of the gate crashing, and a comparison against nothing fails too. The regex types in pre-conditions use Python's regular expression dialect.

Choosing the verdict

on_fail takes ALLOW, BLOCK or ESCALATE, and the most severe finding across every contract that ran decides the call.

Reach for BLOCK when no person inside this mandate should be able to approve the action. Reach for ESCALATE when a person legitimately can, and there is somebody to ask. The shipped spending contract uses both on purpose. A single payment over the per-payment limit escalates, because that is a decision a person can take. A payment that would carry the month past the wallet's budget blocks, because it is not anyone's to approve inside that mandate.

An ESCALATE that cannot reach a person is not an escalation. In a session running in a permission mode that never prompts, the gate falls back to whatever the mandate's escalate_fallback says, and deny is the only answer that keeps an escalation meaningful.

Write the reason for a person

Every check needs a reason, and the compiler refuses a check without one. That sentence is what the agent receives, what the operator sees on the prompt, and what sits on the receipt an auditor reads.

Name the state, not the rule id. "Sanctions screening evidence is missing from the case folder" tells someone what to do. "screening_is_done failed" does not.

Compile after any change

coco compile

The compiler refuses a contract that will not evaluate. A rule with a syntax error, a verdict that is not ALLOW, BLOCK or ESCALATE, a check with no id, a check with no reason, a duplicate contract id, a mandate with no id. Each is reported by name at compile time rather than discovered by a block in the middle of someone's work.

Restart Claude Code afterwards, or restart the container.

Test it before it costs anyone anything

The safest test is observe mode on real traffic, and the fastest is a payload through the gate directly.

echo '{"session_id":"t1","cwd":"/tmp","permission_mode":"default",
       "tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
  | python3 ~/.coco/gate/coco_gate.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Coco: The command is a destructive operation with no safe reading in a governed workflow"}}

Over HTTP the same test is one POST /check. Either way the verdict is written to the ledger, so your test rows are in the report alongside real traffic. Use a session id you can recognise.

Next

On this page