| name | contract-authoring |
| description | Write, validate, and debug hook contract.toml files. Covers the full schema (app, policies, serve, smash, profiles, transports), all supported driver config keys, validation rules, plugin system, and common error codes. Use when creating a new app contract, extending an existing one, or diagnosing contract validation failures.
|
Contract Authoring
What a Contract Is
contract.toml is the runtime configuration for a hook app. It declares:
- Which ingress adapters receive events (
serve)
- Which egress adapters deliver events (
smash)
- How events are routed between them (
routes)
- Which combination of adapters and routes is active (
profiles)
The validator uses deny_unknown_fields throughout — any typo in a key is a hard error.
Top-Level Structure
[app]
[policies]
[serve]
[smash]
[profiles.*]
[transports.*]
[app]
[app]
id = "my-app"
name = "My App"
version = "1.0.0"
description = "..."
[policies]
[policies]
validation_mode = "strict"
allow_no_output = false
no_output_sink = "dlq"
debug mode relaxes non-security checks (empty profile labels) but still enforces all security-critical validation.
[serve] — Ingress
[serve]
[[serve.ingress_adapters]]
id = "..."
driver = "..."
[[serve.routes]]
id = "..."
source_match = "*"
event_type_pattern = "*"
target_topic = "webhooks.core"
Ingress Drivers
http_webhook_ingress
[[serve.ingress_adapters]]
id = "http-ingress"
driver = "http_webhook_ingress"
bind = "0.0.0.0:8080"
path_template = "/webhook/{source}"
plugins = [...]
websocket_ingress
[[serve.ingress_adapters]]
id = "ws-ingress"
driver = "websocket_ingress"
auth_mode = "..."
path_template = "..."
plugins = [...]
mcp_ingest_exposed
[[serve.ingress_adapters]]
id = "mcp-ingress"
driver = "mcp_ingest_exposed"
transport_driver = "..."
bind = "..."
auth_mode = "..."
max_payload_bytes = 65536
tool_name = "..."
path = "..."
token_env = "..."
plugins = [...]
kafka_ingress
[[serve.ingress_adapters]]
id = "kafka-ingress"
driver = "kafka_ingress"
topics = "external.topic"
group_id = "my-consumer"
brokers = "..."
plugins = [...]
[smash] — Egress
[smash]
[[smash.egress_adapters]]
id = "..."
driver = "..."
[[smash.routes]]
id = "..."
source_topic_pattern = "webhooks.core"
event_filters = ["pull_request.*"]
destinations = [
{ adapter_id = "my-adapter", required = true },
]
A route destination with required = true (the default) means delivery failure is fatal. Set required = false for best-effort side-channel outputs.
Egress Drivers
openclaw_http_output
[[smash.egress_adapters]]
id = "openclaw-output"
driver = "openclaw_http_output"
url = "http://127.0.0.1:18789/hooks/agent"
token_env = "OPENCLAW_WEBHOOK_TOKEN"
timeout_seconds = 20
max_retries = 5
plugins = [...]
mcp_tool_output
[[smash.egress_adapters]]
id = "mcp-output"
driver = "mcp_tool_output"
tool_name = "emit_event"
transport_ref = "main"
plugins = [...]
websocket_client_output
[[smash.egress_adapters]]
id = "ws-client"
driver = "websocket_client_output"
url = "wss://example.com/events"
auth_mode = "..."
send_timeout_ms = 5000
retry_policy = "..."
plugins = [...]
websocket_server_output
[[smash.egress_adapters]]
id = "ws-server"
driver = "websocket_server_output"
bind = "0.0.0.0:9000"
path = "/events"
auth_mode = "..."
max_clients = 100
queue_depth_per_client = 256
send_timeout_ms = 5000
plugins = [...]
kafka_output
[[smash.egress_adapters]]
id = "kafka-out"
driver = "kafka_output"
topic = "external.output"
key_mode = "..."
plugins = [...]
[transports.*] — MCP Transports
Only required when using mcp_tool_output. The key becomes the transport_ref value.
stdio_jsonrpc
[transports.main]
driver = "stdio_jsonrpc"
command = "..."
args = [...]
env = { ... }
http_sse
[transports.main]
driver = "http_sse"
url = "http://..."
auth_mode = "..."
[profiles.*]
[profiles.my-profile]
label = "My Profile"
serve_adapters = ["http-ingress"]
smash_adapters = ["openclaw-output"]
serve_routes = ["all-to-core"]
smash_routes = ["core-to-openclaw"]
env = { KEY = "value" }
All IDs in a profile must reference adapters/routes declared in [serve] / [smash]. Missing references are security-critical errors.
Profile Activation
Hook CLI picks the profile matching the --app id or --profile flag:
hook serve --app default-openclaw
hook smash --app default-openclaw
Plugin System
Plugins run in declaration order within an adapter's plugins = [...] list.
plugins = [
{ driver = "event_type_alias", from = "push", to = "git.push" },
{ driver = "require_payload_field", pointer = "/repository/id" },
{ driver = "add_meta_flag", flag = "has-repo" },
]
| Driver | Effect |
|---|
event_type_alias | Remap event type string |
require_payload_field | Fail closed if JSON pointer is missing |
add_meta_flag | Write a deduplicated flag to envelope metadata |
require_payload_field uses JSON Pointer syntax (/field/nested). A missing pointer causes the message to be rejected.
Contract Discovery Order
When running hook serve or hook smash:
--contract <path> — explicit path
--app <id> → apps/<id>/contract.toml
./contract.toml
- Embedded
default-openclaw fallback
Validation Rules and Error Codes
| Code | Cause |
|---|
missing_profile | Profile name not found in contract |
missing_serve_adapter | Profile references undefined serve adapter |
missing_smash_adapter | Profile references undefined smash adapter |
missing_serve_route | Profile references undefined serve route |
missing_smash_route | Profile references undefined smash route |
unsupported_ingress_driver | Active adapter uses unknown driver |
unsupported_egress_driver | Active adapter uses unknown driver |
unsupported_transport_driver | Active transport uses unknown driver |
unknown_adapter_key | Adapter config has an unrecognised key |
missing_required_adapter_key | Required adapter key is absent |
empty_required_adapter_value | Required key exists but is empty/whitespace |
missing_transport_ref | mcp_tool_output transport_ref points to missing transport |
empty_smash_route_destinations | Smash route has no destinations |
inactive_destination_adapter | Route destination not active in profile |
no_smash_outputs | Profile has no active smash outputs and allow_no_output=false |
missing_no_output_sink | allow_no_output=true but no_output_sink not set |
dlq_without_routes | no_output_sink=dlq but no active smash routes |
empty_profile_label | Profile label is empty (strict mode only) |
Unknown drivers in inactive adapters are allowed — you can declare adapters for future profiles without breaking current validation.
Minimal Working Example
[app]
id = "my-app"
name = "My App"
version = "1.0.0"
[serve]
[[serve.ingress_adapters]]
id = "http-ingress"
driver = "http_webhook_ingress"
bind = "0.0.0.0:8080"
[[serve.routes]]
id = "all-to-core"
source_match = "*"
event_type_pattern = "*"
target_topic = "webhooks.core"
[smash]
[[smash.egress_adapters]]
id = "openclaw-output"
driver = "openclaw_http_output"
url = "http://127.0.0.1:18789/hooks/agent"
token_env = "OPENCLAW_WEBHOOK_TOKEN"
timeout_seconds = 20
max_retries = 5
[[smash.routes]]
id = "core-to-openclaw"
source_topic_pattern = "webhooks.core"
destinations = [{ adapter_id = "openclaw-output" }]
[profiles.my-app]
label = "My App"
serve_adapters = ["http-ingress"]
smash_adapters = ["openclaw-output"]
serve_routes = ["all-to-core"]
smash_routes = ["core-to-openclaw"]
Validate before running:
hook debug capabilities
hook serve --app my-app --dry-run