.chat_behavior` (or a channel instance's `chat_behavior`) has the final say.
Each level only overrides the fields it sets, so you can tune one knob per channel and inherit the rest.
> **Legacy `block_reply`.** The older `gateway.block_reply` (and per-channel `block_reply`) flags are still read as the inherited default for `intermediate_replies.enabled` when the newer field is unset.
### Channel support
Human-like delivery is implemented by channels that adopt the `ChatBehaviorChannel` interface: **Bitrix24, Discord, Feishu/Lark, Pancake, Slack, Telegram, WhatsApp, Zalo OA, and Zalo Personal**.
## Channel Comparison
| Feature | Telegram | Bitrix24 | Discord | Slack | Larksuite | Zalo OA | Zalo Pers | WhatsApp |
|---------|----------|----------|---------|-------|--------|---------|-----------|----------|
| **Transport** | Long polling | Webhook (OAuth) | Gateway events | Socket Mode (WS) | WS/Webhook | Long polling | Internal proto | WS bridge |
| **DM support** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| **Group support** | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes |
| **Streaming** | Yes (typing) | Yes | Yes (edit) | Yes (edit) | Yes (card) | No | No | No |
| **Media** | Photos, voice, files | Files (20MB) | Files, embeds | Files (20MB) | Images, files (30MB) | Images (5MB) | -- | JSON |
| **Reply media** | Yes | -- | Yes | -- | Yes | -- | -- | -- |
| **Rich format** | HTML | Text | Markdown | mrkdwn | Cards | Plain text | Plain text | Plain |
| **Thread support** | Yes | -- | -- | -- | -- | -- | -- | -- |
| **Reactions** | Yes | Yes | -- | Yes | Yes | -- | -- | -- |
| **Pairing** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
| **Message limit** | 4,096 | 4,000 | 2,000 | 4,000 | 4,000 | 2,000 | 2,000 | N/A |
## Channel Health Diagnostics
GoClaw tracks the runtime health of each channel instance and provides actionable diagnostics when issues occur. Health state is exposed via the `channels.status` WebSocket method and the dashboard overview page.
### Health States
| State | Meaning |
|-------|---------|
| `registered` | Channel is configured but not yet started |
| `starting` | Channel is initializing |
| `healthy` | Running normally |
| `degraded` | Running with issues |
| `failed` | Stopped due to an error |
| `stopped` | Manually stopped |
### Failure Classification
When a channel fails, GoClaw classifies the error into one of four categories:
| Kind | Typical Cause | Remediation |
|------|---------------|-------------|
| `auth` | Invalid or expired token/secret | Review credentials or re-authenticate |
| `config` | Missing required settings, invalid proxy | Complete required fields in channel settings |
| `network` | Timeout, connection refused, DNS failure | Check upstream service reachability and proxy settings |
| `unknown` | Unrecognized error | Inspect server logs for the full error |
Each failure includes a **remediation hint** — a short operator instruction pointing to the specific UI surface (credentials panel, advanced settings, or details page) where the issue can be resolved. The dashboard surfaces these hints directly on channel cards.
### Health Tracking
The health system tracks failure history per channel:
- **Consecutive failures** — resets when the channel recovers
- **Total failure count** — lifetime counter
- **First/last failure timestamps** — for diagnosing intermittent issues
- **Last healthy timestamp** — when the channel was last operational
---
# Telegram Channel
Telegram bot integration via long polling (Bot API). Supports DMs, groups, forum topics, speech-to-text, and streaming responses.
## Setup
**Create a Telegram Bot:**
1. Message @BotFather on Telegram
2. `/newbot` → choose name and username
3. Copy the token (format: `123456:ABCDEFGHIJKLMNOPQRSTUVWxyz...`)
> **Important — Group Privacy Mode:** By default, Telegram bots run in **privacy mode** and can only see commands (`/`) and @mentions in groups. To let the bot read all group messages (required for history buffer, `require_mention: false`, and group context), message **@BotFather** → `/setprivacy` → select your bot → **Disable**. Without this, the bot will silently ignore most group messages.
**Enable Telegram:**
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"dm_policy": "pairing",
"group_policy": "open",
"allow_from": ["alice", "bob"]
}
}
}
```
## Configuration
All config keys are in `channels.telegram`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `token` | string | required | Bot API token from BotFather |
| `proxy` | string | -- | HTTP proxy (e.g., `http://proxy:8080`) |
| `allow_from` | list | -- | User ID or username allowlist |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `allowlist`, `disabled` |
| `require_mention` | bool | true | Require @bot mention in groups |
| `mention_mode` | string | `"strict"` | `strict` = only respond when @mentioned; `yield` = respond unless another bot is @mentioned (multi-bot groups) |
| `history_limit` | int | 50 | Pending messages per group (0=disabled) |
| `dm_stream` | bool | false | Enable streaming for DMs (edits placeholder) |
| `group_stream` | bool | false | Enable streaming for groups (new message) |
| `draft_transport` | bool | false | Use `sendMessageDraft` for DM streaming (stealth preview, no per-edit notifications) |
| `reasoning_delivery` | string | -- | How reasoning is surfaced: `off`, `streaming_only`, `always_bubbles`. See [Reasoning Delivery](#reasoning-delivery). |
| `reasoning_stream` | bool | true | **Legacy.** Show reasoning tokens as a separate message before the answer. Used only when `reasoning_delivery` is unset. |
| `block_reply` | bool | -- | Override gateway `block_reply` setting for this channel (nil = inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
| `reaction_level` | string | `"off"` | `off`, `minimal` (⏳ only), `full` (⏳💬🛠️✅❌🔄) |
| `media_max_bytes` | int | 20MB | Max media file size |
| `link_preview` | bool | true | Show URL previews |
| `force_ipv4` | bool | false | Force IPv4 for all Telegram API connections |
| `api_server` | string | -- | Custom Telegram Bot API server URL (e.g. `http://localhost:8081`) |
| `stt_proxy_url` | string | -- | STT service URL (for voice transcription) |
| `stt_api_key` | string | -- | Bearer token for STT proxy |
| `stt_timeout_seconds` | int | 30 | Timeout for STT transcription requests |
| `voice_agent_id` | string | -- | Route voice messages to specific agent |
**Media upload size**: The `media_max_bytes` field enforces a hard limit on outbound media uploads sent by the agent (default 20 MB). Files exceeding this limit are silently skipped with a log entry. This does not affect inbound media received from users.
## Group Configuration
Override per-group (and per-topic) settings using the `groups` object.
```json
{
"channels": {
"telegram": {
"token": "...",
"groups": {
"-100123456789": {
"group_policy": "allowlist",
"allow_from": ["@alice", "@bob"],
"require_mention": false,
"topics": {
"42": {
"require_mention": true,
"tools": ["web_search", "file_read"],
"system_prompt": "You are a research assistant."
}
}
},
"*": {
"system_prompt": "Global system prompt for all groups."
}
}
}
}
}
```
Group config keys:
- `group_policy` — Override group-level policy
- `allow_from` — Override allowlist
- `require_mention` — Override mention requirement
- `mention_mode` — Override mention mode (`strict` or `yield`)
- `skills` — Whitelist skills (nil=all, []=none)
- `tools` — Whitelist tools (supports `group:xxx` syntax)
- `system_prompt` — Extra system prompt for this group
- `topics` — Per-topic overrides (key: topic/thread ID)
## Features
### Mention Gating
In groups, bot responds only to messages that mention it (default `require_mention: true`). When not mentioned, messages are stored in a pending history buffer (default 50 messages) and included as context when the bot is mentioned. Replying to a bot message counts as mentioning it.
#### Mention Modes
| Mode | Behavior | Use case |
|------|----------|----------|
| `strict` (default) | Only respond when @mentioned or replied to | Single-bot groups |
| `yield` | Respond to all messages UNLESS another bot/user is @mentioned | Multi-bot shared groups |
**Yield mode** enables multiple bots to coexist in one group without conflicts:
- Bot responds to all messages where no specific @mention targets another bot
- If a user @mentions a different bot, this bot stays silent (yields)
- Messages from other bots are automatically skipped to prevent infinite cross-bot loops
- Cross-bot @commands still work (e.g., `@my_bot help` sent by another bot)
```json
{
"channels": {
"telegram": {
"mention_mode": "yield",
"require_mention": false
}
}
}
```
```mermaid
flowchart TD
MSG["User posts in group"] --> MODE{"mention_mode?"}
MODE -->|strict| MENTION{"Bot @mentioned
or reply?"}
MODE -->|yield| OTHER{"Another bot/user
@mentioned?"}
OTHER -->|Yes| YIELD["Yield — stay silent"]
OTHER -->|No| PROCESS
MENTION -->|No| BUFFER["Add to pending history
(max 50 messages)"]
MENTION -->|Yes| PROCESS["Process now
Include history as context"]
BUFFER --> NEXT["Next mention:
history included"]
```
### Bot Self-Identity in System Prompt
On startup, GoClaw resolves the bot's Telegram username and display name, then injects a short self-identity snippet into the agent system prompt:
```
You are @mybot (My Bot) on this Telegram channel.
```
This tells the agent its own handle so it can correctly interpret @mentions in group conversations — particularly useful in multi-bot groups where other bots' mentions are preserved in the message content after mention stripping.
### Own @Mention Stripping
Before passing message content to the agent, GoClaw strips the bot's own `@username` from the text. This means the agent receives clean input without its own handle. For example, a user message `"@mybot what's the weather?"` is delivered to the agent as `"what's the weather?"`.
Other bots' @mentions are intentionally preserved so the agent can detect cross-bot interactions.
### Group Message Annotation
In group chats, each message is prefixed with a `[From:]` annotation so the agent knows who is speaking:
```
[From: @username (Display Name)]
Message content here
```
The label format depends on available user data:
- Username + display name: `@username (Display Name)`
- Username only: `@username`
- Display name only: `Display Name`
This annotation is also added to DM messages for consistent sender identification.
### Group Concurrency
Group sessions support up to **3 concurrent agent runs**. When this limit is reached, additional messages are queued. This applies to all group and forum topic contexts.
### Forum Topics
Configure bot behavior per forum topic:
| Aspect | Key | Example |
|--------|-----|---------|
| Topic ID | Chat ID + topic ID | `-12345:topic:99` |
| Config lookup | Layered merge | Global → Wildcard → Group → Topic |
| Tool restrict | `tools: ["web_search"]` | Only web search in topic |
| Extra prompt | `system_prompt` | Topic-specific instructions |
### Message Formatting
Markdown output is converted to Telegram HTML with proper escaping:
```
LLM output (Markdown)
→ Extract tables/code → Convert Markdown to HTML
→ Restore placeholders → Chunk at 4,000 chars
→ Send as HTML (fallback: plain text)
```
Tables render as ASCII in `` tags. CJK characters counted as 2-column width.
### Speech-to-Text (STT)
Voice and audio messages can be transcribed:
```json
{
"channels": {
"telegram": {
"stt_proxy_url": "https://stt.example.com",
"stt_api_key": "sk-...",
"stt_timeout_seconds": 30,
"voice_agent_id": "voice_assistant"
}
}
}
```
When a user sends a voice message:
1. File is downloaded from Telegram
2. Sent to STT proxy as multipart (file + tenant_id)
3. Transcript prepended to message: `[audio: filename] Transcript: text`
4. Routed to `voice_agent_id` if configured, else default agent
Transcription runs through GoClaw's unified STT chain, which tries providers in order — `elevenlabs`, then `proxy`. (These are the current provider names; older releases called them `elevenlabs_scribe` and `proxy_stt`.) Telegram preserves the original voice MIME type when forwarding audio to the chain, and the legacy STT-proxy bridge override is keyed by the platform type `telegram`.
### Streaming
Enable live response updates:
- **DMs** (`dm_stream`): Edits the "Thinking..." placeholder as chunks arrive. Uses `sendMessage+editMessageText` by default; set `draft_transport: true` to use `sendMessageDraft` (stealth preview, no per-edit notifications, but may cause "reply to deleted message" artifacts on some clients).
- **Groups** (`group_stream`): Sends placeholder, edits with full response
Disabled by default.
### Reasoning Delivery
When the model emits reasoning ("thinking") tokens, `reasoning_delivery` controls how — or whether — that reasoning shows up in the chat:
| `reasoning_delivery` | Behavior |
|----------------------|----------|
| `streaming_only` | Reasoning appears only in the live streaming lane (the legacy behavior). |
| `always_bubbles` | Forces provider streaming internally and sends reasoning as bounded normal "bubble" messages — even when `dm_stream` / `group_stream` are off. The final answer is still delivered non-streaming. |
| `off` | Reasoning is suppressed in the channel. Traces and usage accounting are unaffected. |
**Backward compatibility.** When `reasoning_delivery` is unset, the legacy `reasoning_stream` boolean is honored: `reasoning_stream: false` resolves to `off`, otherwise to `streaming_only`. An explicit `reasoning_delivery` value always wins over the legacy boolean.
Reasoning bubbles are delivery-only — they are not added to assistant/session history. Telegram is currently the only channel that implements this control (`ReasoningDeliveryChannel`).
### Media Handling
**Albums / multi-attachment coalescing.** When a user sends an album (several photos or files grouped on the client), Telegram delivers it as N separate updates that share one `MediaGroupID`. GoClaw buffers album members at the channel layer (500 ms silence window) and synthesizes them into **one** inbound message, so the agent replies once instead of once per attachment. The buffer key is `(chatID, MediaGroupID)`; the sender is pinned on the first member, and a member arriving with a mismatched sender is dropped as a defensive measure.
**Outbound album batching.** When the agent sends several files at once — via the `send_file` tool's `attachments: [{path, caption?}, ...]` form — Telegram groups compatible outbound media into `sendMediaGroup` album chunks of **2–10** items. Photos and videos can share a chunk; documents group only with documents and audio only with audio. Voice-mode audio, singleton chunks, oversized images that fall back to being sent as documents, and otherwise-incompatible runs degrade gracefully to ordered single-send. File order, MIME type, filename, and captions are preserved.
**Outbound upload limit.** Outbound media is validated against the upload ceiling before sending — files over the limit are rejected with a clear "outbound media too large" error rather than failing mid-upload. The ceiling is 50 MB on the official Bot API and 200 MB when a local Bot API server (`api_server`) is configured (or higher if `media_max_bytes` is raised above that).
**Archive path preservation.** Inbound files keep their original extension when persisted (e.g. a `codex.zip` upload stays `.zip` rather than being rewritten to a generic name), so skills and document tools can detect and extract archives correctly downstream.
### Reactions
Show emoji status on user messages. Set `reaction_level`:
- `off` — No reactions (default)
- `minimal` — Only terminal states (done/error)
- `full` — All status transitions with debouncing and stall detection
**Status → Emoji mapping** (use `/reactions` in chat to see this legend):
| Status | Emoji | Description |
|--------|-------|-------------|
| queued | 👀 | Waiting to process |
| thinking | 🤔 | Processing your request |
| tool | ✍ | Executing a tool |
| coding | 👨💻 | Running code |
| web | ⚡ | Browsing / API call |
| done | 👍 | Completed |
| error | 💔 | Something went wrong |
| stallSoft | 🥱 | No activity for 10s |
| stallHard | 😨 | No activity for 30s |
Each status has fallback emoji variants in case the primary emoji is restricted by the chat's allowed reactions. Intermediate states (thinking, tool, etc.) are debounced at 700ms to avoid reaction spam.
> **No placeholder tool-status text.** Deterministic tool-status updates are surfaced through reactions only — they no longer emit a separate "tool" text message in the channel. Platform reactions and explicit reasoning delivery remain independent behaviors.
### Bot Commands
Commands processed before message enrichment:
| Command | Behavior | Restricted |
|---------|----------|-----------|
| `/help` | Show command list | -- |
| `/start` | Passthrough to agent | -- |
| `/stop` | Cancel current run | -- |
| `/stopall` | Cancel all runs | -- |
| `/reset` | Clear session history | Writers only |
| `/status` | Bot status + username | -- |
| `/tasks` | Team task list | -- |
| `/task_detail ` | View task | -- |
| `/subagents` | List all active subagent tasks with status | -- |
| `/subagent ` | Show detailed view of a subagent task (DB-backed) | -- |
| `/reactions` | Show reaction emoji legend (status → emoji mapping) | -- |
| `/addwriter` | Add group file writer | Writers only |
| `/removewriter` | Remove group file writer | Writers only |
| `/writers` | List group writers | -- |
Writers are group members allowed to run sensitive commands (`/reset`, file writes). Manage via `/addwriter` and `/removewriter` (reply to target user).
## Networking Isolation
Each Telegram instance maintains an isolated HTTP transport — no shared connection pools between bots. This prevents cross-bot contention and enables per-account network routing.
| Option | Default | Description |
|--------|---------|-------------|
| `force_ipv4` | false | Force IPv4 for all connections. Useful for sticky routing or when IPv6 is broken/blocked. |
| `proxy` | -- | HTTP proxy URL for this specific bot instance (e.g. `http://proxy:8080`). |
| `api_server` | -- | Custom Telegram Bot API server. Useful with local Bot API server or private deployments. |
**Sticky IPv4 fallback**: When `force_ipv4: true`, the dialer is locked to `tcp4` at startup, ensuring consistent source IP across all requests to Telegram. This helps with rate limit management in environments with unstable IPv6.
```json
{
"channels": {
"telegram": {
"token": "...",
"force_ipv4": true,
"proxy": "http://proxy.example.com:8080",
"api_server": "http://localhost:8081"
}
}
}
```
## Group-to-Supergroup Migration
When a Telegram group is upgraded to a supergroup, the chat ID changes. GoClaw handles this automatically:
- **Inbound detection** — When a `MigrateToChatID` message arrives, GoClaw updates all DB references (paired_devices, sessions, channel_contacts) atomically and invalidates in-memory caches
- **Send-path retry** — If a send fails because the group was migrated, GoClaw detects the new chat ID from the Telegram API error, updates DB, and retries the send automatically
- **Idempotent** — Safe to trigger multiple times; duplicate migrations are no-ops
No configuration needed. Check logs for `telegram: migrating group chat` entries if troubleshooting.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Bot not responding in groups | Ensure privacy mode is disabled via @BotFather (`/setprivacy` → Disable). Then check `require_mention=true` (default) — mention bot or reply to its message. For multi-bot groups, try `mention_mode: "yield"`. |
| Media downloads fail | Verify bot has `Can read all group messages` in @BotFather (`/setprivacy` → Disable). Check `media_max_bytes` limit. |
| STT transcription missing | Verify STT proxy URL and API key. Check logs for timeout. |
| Streaming not working | Enable `dm_stream` or `group_stream`. Ensure provider supports streaming. |
| Topic routing fails | Check topic ID in config keys (integer thread ID). Generic topic (ID=1) stripped in Telegram API. |
## What's Next
- [Overview](/channels-overview) — Channel concepts, policies, and [inbound debounce](/channels-overview#inbound-debounce)
- [Discord](/channel-discord) — Discord bot setup
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
- [Sessions & History](../core-concepts/sessions-and-history.md) — Conversation history
---
# Bitrix24 Channel
Bitrix24 integration via the **imbot** (chatbot) API. GoClaw registers a bot on a Bitrix24 portal, receives chat events through a webhook, and replies as the bot. One GoClaw gateway can serve many bots across many portals, and several bots can share a single portal (they share OAuth tokens and the refresh loop).
Unlike token-based channels (Telegram, Slack), Bitrix24 uses an **OAuth portal install flow**: an admin authorizes the GoClaw app on their portal once, GoClaw stores and auto-refreshes the tokens, then registers bots on demand.
## How It Works
```mermaid
flowchart LR
Admin["Portal admin"] -->|"visits /bitrix24/install"| Install["Install handler
OAuth exchange"]
Install -->|"stores tokens"| Portal["bitrix_portals row
(encrypted)"]
Channel["bitrix24 channel"] -->|"imbot.register"| BX["Bitrix24 portal"]
BX -->|"POST /bitrix24/events"| Webhook["Webhook router"]
Webhook -->|"dedup + dispatch"| Agent["Agent loop"]
Agent -->|"imbot.message.add"| BX
```
1. **Seed a portal row** with the app's `client_id` / `client_secret` (CLI or dashboard).
2. **Admin authorizes** by visiting the install URL — GoClaw exchanges the code for tokens and persists them encrypted.
3. **Configure a channel instance** that points at the portal and names the bot.
4. On start, the channel calls `imbot.register` (idempotently) and wires the returned bot ID into the webhook router.
5. Inbound chat events hit `/bitrix24/events`; GoClaw deduplicates, dispatches to the agent loop, and replies as the bot.
## Setup
### 1. Create a Bitrix24 application
In your Bitrix24 portal, create either a **Local application** or a **Marketplace (OAuth2) application**. You need:
- `client_id` (application ID)
- `client_secret` (application key)
- OAuth scopes including `imbot` (chatbot) and `user` (so GoClaw can resolve sender display names)
Set the application handler / installer URL to your GoClaw public URL:
- **Install / installer URL:** `https:///bitrix24/install`
- **Events:** registered automatically by GoClaw (`https:///bitrix24/events`)
> **Public URL required.** Bitrix24 needs absolute, publicly reachable URLs for the event handlers it calls. GoClaw captures the public URL automatically from the install callback. If your gateway sits behind a tunnel or rotating ingress, see [Public URL handling](#public-url-handling).
### 2. Seed the portal row
A `bitrix_portals` row must exist before the admin authorizes the app. Create it via CLI:
```bash
goclaw bitrix-portal create \
--tenant-id \
--name acme \
--domain acme.bitrix24.com \
--client-id \
--client-secret
```
The `--name` (e.g. `acme`) is the portal key your channel config references. The `--domain` is the bare host — no scheme, no trailing slash.
> **Self-hosted portals are supported.** In addition to Bitrix24 cloud domains (`*.bitrix24.{com,eu,vn,...}` and `*.bitrix.info`), `--domain` accepts a **self-hosted Bitrix24 FQDN** such as `bx.mycompany.com` (an optional `:port` is allowed). Self-hosted domains pass an SSRF-safe validation step: the hostname is resolved and **every** returned IP is checked, loopback/private/metadata IP ranges are rejected, `localhost` / `.local` / `.localhost` names are blocked, and any port must be in the range 1–65535. Cloud domains skip this check (they are Bitrix-operated and trusted).
> Set `GOCLAW_ENCRYPTION_KEY` before running this. Credentials and tokens are stored AES-256-GCM encrypted; without the key they are stored in plaintext (with a warning).
You can also create portals from the dashboard, which returns the install URL directly.
### 3. Authorize the portal
Direct the portal admin to the install URL printed by the create command:
```
https:///bitrix24/install?state=:acme
```
The admin authorizes the app inside Bitrix24. GoClaw exchanges the code for tokens, captures the public URL, and shows a small auto-closing success page. The portal is now **installed**.
Both install flows are supported automatically:
- **Marketplace (OAuth2):** `code` is exchanged for tokens.
- **Local application:** Bitrix24 POSTs the tokens directly (`AUTH_ID` / `REFRESH_ID`); no exchange step.
### 4. Configure the channel instance
Create a `bitrix24` channel instance whose config points at the portal and names the bot:
```json
{
"portal": "acme",
"bot_code": "support-bot",
"bot_name": "GoClaw Assistant",
"bot_type": "B",
"dm_policy": "pairing",
"group_policy": "open"
}
```
On start, the channel registers the bot (idempotently — restarts never spawn duplicate bots) and begins receiving events. If the portal is not yet installed, the channel reports a `failed` health state with the message "Portal not installed — visit /bitrix24/install".
## Configuration
Channel instance config fields:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `portal` | string | required | Portal name (matches `bitrix-portal create --name`) |
| `bot_code` | string | required | Stable bot key passed to `imbot.register`. Restarts reuse the same code. |
| `bot_name` | string | required | Bot display name on the portal |
| `bot_avatar` | string | -- | Image URL; fetched and base64-encoded at start (http/https only, ≤256 KB) |
| `bot_type` | string | `"B"` | `"B"` = standard internal chatbot; `"O"` = Open Channel (customer-facing) bot |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `allowlist`, `disabled` |
| `allow_from` | list | -- | DM sender allowlist |
| `group_allow_from` | list | -- | Group sender allowlist |
| `require_mention` | bool | true | Require @mention in group chats |
| `text_chunk_limit` | int | 4000 | Max characters per outbound chunk |
| `media_max_mb` | int | 20 | Max media size in MB |
| `streaming` | bool | true | Stream responses |
| `reaction_level` | string | `"minimal"` | `off`, `minimal`, `full` |
| `history_limit` | int | -- | Pending group messages held as context |
| `block_reply` | bool | -- | Override gateway `block_reply` (nil = inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
| `public_url` | string | -- | Per-instance public URL override (legacy; prefer the auto-captured portal URL) |
| `mcp_server_name` | string | -- | MCP server name for per-user credential provisioning (see [MCP integration](#mcp-integration)) |
| `mcp_base_url` | string | -- | MCP server base URL; must be set together with `mcp_server_name` |
### Bot types
| Type | Audience | Notes |
|------|----------|-------|
| `B` | Internal staff | Sees DMs always; sees group messages only when @mentioned. Pairs with users and can receive per-user MCP credentials. Recommended: `dm_policy: pairing`, `group_policy: open`. |
| `O` | External customers (Open Channel widget) | After registration, an admin must attach the bot to an Open Channel queue in the Bitrix24 UI. Recommended: `dm_policy: open`. Per-user MCP provisioning is skipped (customers are transient). |
> GoClaw only accepts `B` or `O`. Unknown types are rejected at startup to avoid a bot that silently receives no events.
## Public URL handling
`imbot.register` requires absolute URLs for its event handlers. GoClaw resolves the public URL in this order:
1. **Auto-captured portal URL** — recorded from the actual `/bitrix24/install` request Bitrix24 sent. This is the preferred source because it is proven reachable.
2. **`public_url` in channel config** — a legacy fallback used only when (1) is empty (e.g. a portal installed on an older GoClaw release).
If your public URL changes (tunnel rotated, redeployed to a new host), re-run the install flow to recapture it, or backfill it:
```bash
goclaw bitrix-portal set-public-url \
--tenant-id \
--name acme \
--url https://goclaw.example.com
```
To push a changed URL back into Bitrix24's event handlers without recreating the bot, restart the channel with:
```bash
BITRIX24_FORCE_REREGISTER=1 goclaw gateway
```
This bypasses the cached bot state and re-runs `imbot.register` with the current URL and config.
## Features
### Contact enrichment
Bitrix24 webhook events do not carry sender display names. On first sight of a user, GoClaw calls `user.get` to resolve a friendly name (e.g. `Name Last_Name`, falling back to login or email) and caches it per channel (1 hour for hits, 5 minutes for failures). This requires the `user` OAuth scope — without it, names stay blank and a debug log explains why. Enrichment is best-effort: a failure never blocks message processing.
### Inbound deduplication
Bitrix24 retries event delivery on any non-2xx response (and may fire 3–5 retries in a burst). GoClaw deduplicates inbound events by `domain:event_type:message_id` using a bounded LRU cache, so a retried delivery returns `{"duplicate":true}` (HTTP 200) and never triggers a second agent run — important because Bitrix24 bills per bot and duplicate runs would double token usage.
### Token refresh
OAuth access tokens (1-hour TTL) are refreshed automatically in the background, slightly ahead of expiry, with exponential backoff on failure. Concurrent requests coalesce so only one refresh runs at a time. If the refresh token becomes invalid, the portal requires a reinstall.
### Inbound debounce
Like other channels, Bitrix24 honors the gateway-wide inbound debounce window that merges rapid messages from the same sender into one agent run. See [Inbound Debounce](/channels-overview#inbound-debounce) in the overview.
## MCP integration
The Bitrix24 channel can lazily provision **per-user MCP credentials** so each user's agent tools act on Bitrix24 as that user. This is optional and staged — install the channel first, layer MCP on later.
Set both fields to enable it:
```json
{
"portal": "acme",
"bot_code": "support-bot",
"bot_name": "GoClaw Assistant",
"mcp_server_name": "bitrix-mcp",
"mcp_base_url": "https://mcp.example.com"
}
```
How it works:
1. On a user's first message, the channel checks for existing MCP credentials.
2. If absent (or near expiry), it POSTs to `{mcp_base_url}/api/auto-onboard`, forwarding that user's Bitrix24 OAuth tokens.
3. The MCP server authenticates the call by verifying the access token against Bitrix24 `profile` (no shared admin secret needed), then returns a per-user API key.
4. GoClaw stores the key; downstream agent tool calls use it transparently.
Notes:
- Both `mcp_server_name` and `mcp_base_url` must be set together, or neither (half-config fails at startup). The named MCP server row must also exist.
- Provisioning is **best-effort**: if any step fails, the user still gets a reply (without MCP tools), and a one-time notice is sent so they know to contact an admin.
- Skipped entirely for Open Channel bots (`bot_type: "O"`) — transient customers have no user mapping.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Channel `failed`: "Portal not installed" | Admin must visit `/bitrix24/install` to authorize the app first. |
| `imbot.register` fails: public_url not set | The portal has no captured public URL. Re-run install, or `goclaw bitrix-portal set-public-url`. |
| Bot registered but receives no chat events | Install was not finalized — Bitrix24 suppresses events until `BX24.installFinish()` runs. Reinstall via `/bitrix24/install` so the success page signals completion. |
| Contact names stay blank | OAuth scope missing `user`. Add the scope and reinstall; names appear within 5 minutes. |
| Open Channel bot silent to customers | `bot_type: "O"` needs `dm_policy: "open"`, and an admin must attach the bot to an Open Channel queue in the Bitrix24 UI. |
| Event handler URL stale after redeploy | Set `BITRIX24_FORCE_REREGISTER=1` and restart to push the new URL into Bitrix24. |
| Want to inspect raw events | Set `BITRIX24_LOG_RAW_EVENT=1` at process start (credentials are redacted). Leave off in production — it logs message text. |
| Self-hosted domain rejected | The FQDN must resolve to a public IP. SSRF validation blocks loopback/private/metadata IP ranges and `localhost` / `.local` / `.localhost` names; ports must be 1–65535. |
## What's Next
- [Overview](/channels-overview) — Channel concepts, policies, and inbound debounce
- [Telegram](/channel-telegram) — Telegram bot setup
- [Slack](/channel-slack) — Slack Socket Mode integration
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Discord Channel
Discord bot integration via the Discord Gateway API. Supports DMs, servers, threads, and streaming responses via message editing.
## Setup
**Create a Discord Application:**
1. Go to https://discord.com/developers/applications
2. Click "New Application"
3. Go to "Bot" tab → "Add Bot"
4. Copy the token
5. Ensure `Message Content Intent` is enabled under "Privileged Gateway Intents"
**Add Bot to Server:**
1. OAuth2 → URL Generator
2. Select scopes: `bot`
3. Select permissions: `Send Messages`, `Read Message History`, `Read Messages/View Channels`
4. Copy the generated URL and open in browser
**Enable Discord:**
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"dm_policy": "open",
"group_policy": "open",
"allow_from": ["alice_id", "bob_id"]
}
}
}
```
## Configuration
All config keys are in `channels.discord`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `token` | string | required | Bot token from Discord Developer Portal |
| `allow_from` | list | -- | User ID allowlist |
| `dm_policy` | string | `"open"` | `open`, `allowlist`, `pairing`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `allowlist`, `disabled` |
| `require_mention` | bool | true | Require @bot mention in servers (channels) |
| `history_limit` | int | 50 | Pending messages per channel (0=disabled) |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
## Features
### Gateway Intents
Automatically requests `GuildMessages`, `DirectMessages`, and `MessageContent` intents on startup.
### Message Limits
Discord enforces 2,000 characters per message. Responses longer than this are split at newline boundaries.
### Placeholder Editing
Bot sends "Thinking..." placeholder immediately, then edits it with the actual response. This provides visual feedback while the agent processes.
```mermaid
flowchart TD
SEND["Send 'Thinking...'
placeholder"]
SEND --> PROCESS["Agent processes
& streaming chunks"]
PROCESS --> EDIT["Edit message
with response"]
EDIT --> DONE["Response complete"]
```
### Mention Gating
In servers (channels), the bot requires being mentioned by default (`require_mention: true`). Pending messages are stored in a history buffer. When the bot is mentioned, history is included as context.
**Group @mention detection:** A message is counted as an @mention when the bot's user ID appears in the `mentions` array of the Discord event, *or* when the message is a direct reply to one of the bot's own messages. Both conditions are checked before any policy or pairing logic runs.
**Pairing replies in groups:** When `group_policy: "pairing"` is set and `require_mention: true` (the default), the bot only sends a pairing invite to an unpaired user if that user explicitly @mentioned the bot (or replied to the bot). Messages that do not address the bot are silently added to the group history buffer and never trigger a pairing reply. Set `require_mention: false` to send pairing invites unconditionally in groups (not recommended for public servers).
### Typing Indicator
While the agent processes, a typing indicator is shown (9-second keepalive). The typing indicator stops automatically after successful message delivery.
### Thread Support
The bot automatically detects and responds in Discord threads. Responses stay in the same thread.
### Thread History Backfill
When the bot is mentioned **inside a Discord thread**, GoClaw pulls in the recent thread context before answering — so a mid-thread mention isn't answered blind.
- Fetches up to **25** prior thread messages (before the trigger) via the Discord REST API and prepends their text as context.
- Downloads up to **15** prior attachments into the inbound media pipeline, bounded to **5 MB per file**.
- The whole backfill is bounded by a **30-second** timeout.
This is **thread-only**. If the bot is missing the `READ_MESSAGE_HISTORY` permission or the REST call fails, GoClaw falls back gracefully to just the current message.
### Multi-File Delivery
Discord delivers multiple files (plus optional text) in a **single message**, up to 10 attachments — so a batch `send_file` lands as one tidy message rather than a flood. See [Multi-Attachment Delivery](/channels-overview#multi-attachment-delivery-batching) in the overview.
### Media from Replied-to Messages
When a user replies to a message that contains media attachments, GoClaw extracts those attachments and includes them in the inbound message context. This lets the agent see and process media even when it was originally shared in a previous turn. Attachment source URLs are preserved in media tags, so agents can reference the original Discord CDN URL.
### Group Media History
Media files (images, video, audio) sent in group conversations are tracked in message history, allowing agents to reference previously shared media.
### Bot Identity
On startup, the bot fetches its own user ID via `@me` endpoint to avoid responding to its own messages.
### Allowlist and Pairing Policy
`dm_policy` and `group_policy` work as documented — `pairing`, `allowlist`, and `open` modes are handled exclusively by the policy evaluation layer. There is no additional allowlist gate after the policy check, so paired users are not wrongly rejected when an `allow_from` list is also configured. If a user is paired but also listed in `allow_from`, both conditions are satisfied and the message proceeds normally.
### Group File Writer Management
Discord supports slash-command-based management of group file writers (similar to Telegram's writer restriction). In server channels, write-sensitive operations can be restricted to designated writers:
| Command | Description |
|---------|-------------|
| `/addwriter` | Add a group file writer (reply to target user) |
| `/removewriter` | Remove a group file writer |
| `/writers` | List current group file writers |
Writers are managed per-group. The group ID format used internally is `group:discord:{channelID}`.
## Common Patterns
### Sending to a Channel
```go
manager.SendToChannel(ctx, "discord", "channel_id", "Hello!")
```
### Group Configuration
Per-guild/channel overrides are not yet supported in the Discord channel implementation. Use global `allow_from` and policies.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Bot doesn't respond | Check bot has necessary permissions. Verify `require_mention` setting. Ensure bot can read messages (`Message Content Intent` enabled). |
| "Unknown Application" error | Token is invalid or expired. Regenerate bot token. |
| Placeholder editing fails | Ensure bot has `Manage Messages` permission. Discord may revoke this during setup. |
| Message split incorrectly | Long responses are split at newlines. Control message length via model `max_tokens`. |
| Bot mentions itself | Check Discord permissions. Bot should not have `@everyone` or `@here` in responses. |
| Thread context missing | Thread history backfill needs `READ_MESSAGE_HISTORY`. Grant the permission; only works inside threads. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Telegram](/channel-telegram) — Telegram bot setup
- [Larksuite](/channel-feishu) — Larksuite integration with streaming cards
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Feishu Channel
[Feishu](https://www.feishu.cn/) (飞书) messaging integration for China users — supporting DMs, groups, streaming cards, and real-time updates via WebSocket or webhook.
## Setup
**Create Feishu App:**
1. Go to https://open.feishu.cn
2. Create custom app → fill Basic Information
3. Under "Bots" → enable "Bot" capability
4. Set bot name and avatar
5. Copy `App ID` and `App Secret`
6. Grant permissions: `im:message`, `im:message.p2p_msg:send`, `im:message.group_msg:send`, `contact:user.id:readonly`
**Enable Feishu:**
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"connection_mode": "websocket",
"domain": "feishu",
"dm_policy": "pairing",
"group_policy": "open"
}
}
}
```
## Configuration
All config keys are in `channels.feishu`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `app_id` | string | required | App ID from Feishu Developer Console |
| `app_secret` | string | required | App Secret from Feishu Developer Console |
| `encrypt_key` | string | -- | Optional message encryption key |
| `verification_token` | string | -- | Optional webhook verification token |
| `domain` | string | `"feishu"` | `"feishu"` for China, `"lark"` for Larksuite |
| `connection_mode` | string | `"websocket"` | `"websocket"` or `"webhook"` |
| `webhook_port` | int | 3000 | Port for webhook server (0=mount on gateway mux) |
| `webhook_path` | string | `"/feishu/events"` | Webhook endpoint path |
| `allow_from` | list | -- | User ID allowlist (DMs) |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `allowlist`, `disabled` |
| `group_allow_from` | list | -- | Group ID allowlist |
| `require_mention` | bool | true | Require bot mention in groups |
| `topic_session_mode` | string | `"disabled"` | `"disabled"` or `"enabled"` for thread isolation |
| `text_chunk_limit` | int | 4000 | Max text characters per message |
| `media_max_mb` | int | 30 | Max media file size (MB) |
| `render_mode` | string | `"auto"` | `"auto"` (detect), `"card"`, `"raw"` |
| `streaming` | bool | true | Enable streaming card updates |
| `reaction_level` | string | `"off"` | `off`, `minimal` (⏳ only), `full` |
| `history_limit` | int | -- | Max messages to load from history |
| `block_reply` | bool | -- | Block reply-to-message context |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
| `stt_proxy_url` | string | -- | Speech-to-text proxy URL |
| `stt_api_key` | string | -- | Speech-to-text API key |
| `stt_tenant_id` | string | -- | Speech-to-text tenant ID |
| `stt_timeout_seconds` | int | -- | Speech-to-text request timeout |
| `voice_agent_id` | string | -- | Agent ID for voice message handling |
## Transport Modes
### WebSocket (Default)
Persistent connection with auto-reconnect. Recommended for low latency.
```json
{
"connection_mode": "websocket"
}
```
### Webhook
Feishu sends events via HTTP POST. Choose:
1. **Mount on gateway mux** (`webhook_port: 0`): Handler shares main gateway port
2. **Separate server** (`webhook_port: 3000`): Dedicated webhook listener
```json
{
"connection_mode": "webhook",
"webhook_port": 0,
"webhook_path": "/feishu/events"
}
```
Then configure the webhook URL in Feishu Developer Console:
- Gateway mux: `https://your-gateway.com/feishu/events`
- Separate server: `https://your-webhook-host:3000/feishu/events`
## Features
### Streaming Cards
Real-time updates delivered as interactive card messages with animation:
```mermaid
flowchart TD
START["Agent starts responding"] --> CREATE["Create streaming card"]
CREATE --> SEND["Send card message
(streaming_mode: true)"]
SEND --> UPDATE["Update card text
with accumulated chunks
(throttled: 100ms min)"]
UPDATE -->|"More chunks"| UPDATE
UPDATE -->|"Done"| CLOSE["Close stream
(streaming_mode: false)"]
CLOSE --> FINAL["User sees full response"]
```
Updates throttled to prevent rate limiting. Display uses 50ms animation frequency (2-character steps).
### Media Handling
**Inbound**: Images, files, audio, video, stickers auto-downloaded and saved:
| Type | Extension |
|------|-----------|
| Image | `.png` |
| File | Original extension |
| Audio | `.opus` |
| Video | `.mp4` |
| Sticker | `.png` |
Max 30 MB by default (`media_max_mb`).
**Outbound**: Files auto-detected and uploaded with correct type (opus, mp4, pdf, doc, xls, ppt, or stream).
**Rich post messages**: GoClaw also extracts images embedded in Feishu rich-text `post` messages (not only standalone image messages). Images within a post body are downloaded and included alongside other media in the inbound message context.
### @Mention Support
The bot sends native Feishu @mentions in group messages. When the agent response contains `@open_id` patterns (e.g. `@ou_abc123`), they are automatically converted to native Lark `at` elements that trigger real notifications to the mentioned user. This works in both `post` text messages and interactive card messages.
### Mention Resolution
Feishu sends placeholder tokens (e.g., `@_user_1`). Bot parses mention list and resolves to `@DisplayName`.
### Thread Session Isolation
When `topic_session_mode: "enabled"`, each thread gets isolated conversation:
```
Session key: "{chatID}:topic:{rootMessageID}"
```
Different threads in same group maintain separate histories.
### Slash Commands (File Writer Management)
In group chats, group members can manage file-write permissions using slash commands:
| Command | Description |
|---------|-------------|
| `/addwriter <@mention or reply>` | Grant file-write permission to a user in the group |
| `/removewriter <@mention or reply>` | Revoke file-write permission from a user |
| `/writers` | List all users with file-write permissions in the group |
**How to specify the target user:** Reply to the user's message and send the command, or @mention them in the same message. Self-grant is supported by @mentioning yourself.
**Authorization:** Only existing file writers can manage the list. When the list is empty, the first caller can seed it by specifying an explicit target.
> These commands work in group chats only. DMs are rejected.
### Lark Docx Auto-Fetch
When a Lark docx URL is pasted in chat, GoClaw automatically detects and fetches the document content via the Lark API and inlines it into the agent's prompt — no tool call required.
**Supported URL formats:**
- `https://*.feishu.cn/docx/`
- `https://*.larksuite.com/docx/`
**Required app permission scope:** `docx:document:readonly` — add this in your Feishu Developer Console under Permissions & Scopes.
**Implementation details:**
- LRU cache: 128 entries, 5-minute TTL (repeated links in the same session are served from cache)
- Content truncated at 8,000 runes to fit the agent's context window
- Duplicate doc IDs in the same message are collapsed — each doc is fetched only once
> Only `/docx/` URLs are supported. Sheets, Base, Wiki, and other Lark document types are out of scope.
### list_group_members Tool
When connected to a Feishu channel, agents have access to the `list_group_members` tool. It returns all members of the current group chat with their `open_id` and display name.
```
list_group_members(channel?, chat_id?) → { count, members: [{ member_id, name }] }
```
Use cases: checking who is in a group, identifying members before mentioning them, attendance tracking. To @mention a member in a reply, use `@member_id` (e.g. `@ou_abc123`) — the bot converts it to a native Feishu mention with notification.
> This tool is only available on Feishu/Lark channels. It will not appear in the tool list for other channel types.
### Per-Topic Tool Allow List
Forum topics support their own tool whitelist. Configure under the agent's tool settings or channel metadata:
| Value | Behavior |
|-------|----------|
| `nil` (omit) | Inherit parent group's tool allow list |
| `[]` (empty) | No tools allowed in this topic |
| `["web_search", "group:fs"]` | Only these tools allowed |
The `group:fs` prefix selects all tools in the `fs` (Feishu) tool group. This follows the same `group:xxx` syntax used in Telegram topic config.
### Speech-to-Text
Voice messages can be transcribed by configuring an STT service:
```json
{
"stt_proxy_url": "https://your-stt-service.com",
"stt_api_key": "YOUR_STT_KEY",
"stt_timeout_seconds": 30
}
```
Set `voice_agent_id` to route transcribed voice messages to a specific agent.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Invalid app credentials" | Check app_id and app_secret. Ensure app is published. |
| Webhook not receiving events | Verify webhook URL is publicly accessible. Check Feishu Developer Console event subscriptions. |
| WebSocket keeps disconnecting | Check network. Verify app has `im:message` permission. |
| Streaming cards not updating | Ensure `streaming: true`. Check `render_mode` (auto/card). Messages shorter than limit render as plain text. |
| Media upload fails | Verify file type matches. Check file size under `media_max_mb`. |
| Mention not parsed | Ensure bot is mentioned. Check mention list in webhook payload. |
| Wrong domain | China users must set `domain: "feishu"`. International users use `domain: "lark"`. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Larksuite](/channel-larksuite) — Larksuite (international) setup
- [Telegram](/channel-telegram) — Telegram bot setup
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Larksuite Channel
[Larksuite](https://www.larksuite.com/) messaging integration supporting DMs, groups, streaming cards, and real-time updates via WebSocket or webhook.
## Setup
**Create Larksuite App:**
1. Go to https://open.larksuite.com
2. Create custom app → fill Basic Information
3. Under "Bots" → enable "Bot" capability
4. Set bot name and avatar
5. Copy `App ID` and `App Secret`
6. Grant the required API scopes (see [Required API Scopes](#required-api-scopes) below)
7. Set Contact Range to **"All members"** under Permissions & Scopes → Contacts
8. Publish the app version (scopes take effect only after publishing)
**Enable Larksuite:**
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "YOUR_APP_ID",
"app_secret": "YOUR_APP_SECRET",
"connection_mode": "websocket",
"domain": "lark",
"dm_policy": "pairing",
"group_policy": "open"
}
}
}
```
## Configuration
All config keys are in `channels.feishu`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `app_id` | string | required | App ID from Larksuite Developer Console |
| `app_secret` | string | required | App Secret from Larksuite Developer Console |
| `encrypt_key` | string | -- | Optional message encryption key |
| `verification_token` | string | -- | Optional webhook verification token |
| `domain` | string | `"lark"` | `"lark"` (Larksuite) or custom domain |
| `connection_mode` | string | `"websocket"` | `"websocket"` or `"webhook"` |
| `webhook_port` | int | 3000 | Port for webhook server (0=mount on gateway mux) |
| `webhook_path` | string | `"/feishu/events"` | Webhook endpoint path |
| `allow_from` | list | -- | User ID allowlist (DMs) |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `allowlist`, `disabled` |
| `group_allow_from` | list | -- | Group ID allowlist |
| `require_mention` | bool | true | Require bot mention in groups |
| `topic_session_mode` | string | `"disabled"` | `"disabled"` or `"enabled"` for thread isolation |
| `text_chunk_limit` | int | 4000 | Max text characters per message |
| `media_max_mb` | int | 30 | Max media file size (MB) |
| `render_mode` | string | `"auto"` | `"auto"` (detect), `"card"`, `"raw"` |
| `streaming` | bool | true | Enable streaming card updates |
| `reaction_level` | string | `"off"` | `off`, `minimal` (⏳ only), `full` |
## Transport Modes
### WebSocket (Default)
Persistent connection with auto-reconnect. Recommended for low latency.
```json
{
"connection_mode": "websocket"
}
```
### Webhook
Larksuite sends events via HTTP POST. Choose:
1. **Mount on gateway mux** (`webhook_port: 0`): Handler shares main gateway port
2. **Separate server** (`webhook_port: 3000`): Dedicated webhook listener
```json
{
"connection_mode": "webhook",
"webhook_port": 0,
"webhook_path": "/feishu/events"
}
```
Then configure the webhook URL in Larksuite Developer Console:
- Gateway mux: `https://your-gateway.com/feishu/events`
- Separate server: `https://your-webhook-host:3000/feishu/events`
## Required API Scopes
Your Larksuite app needs these 15 scopes. The Dashboard shows the full list in a collapsible panel when creating or editing a Feishu channel.
| Scope | Purpose |
|-------|---------|
| `im:message` | Core messaging |
| `im:message:readonly` | Read messages (reply context) |
| `im:message.p2p_msg:send` | Send DMs |
| `im:message.group_msg:send` | Send group messages |
| `im:message.group_at_msg` | Send @-mention messages |
| `im:message.group_at_msg:readonly` | Read @-mention messages |
| `im:chat` | Chat management |
| `im:chat:readonly` | Read chat info |
| `im:resource` | Upload/download media |
| `contact:user.base:readonly` | Read user profiles |
| `contact:user.id:readonly` | Resolve user IDs |
| `contact:user.employee_id:readonly` | Resolve employee IDs |
| `contact:user.phone:readonly` | Resolve phone numbers |
| `contact:user.email:readonly` | Resolve emails |
| `contact:department.id:readonly` | Department lookup |
> **Important:** After granting scopes, set **Contact Range** to **"All members"** under Permissions & Scopes → Contacts, then publish a new app version. Without this, contact resolution returns empty names.
## Features
### Reply Context
When a user replies to a message in a DM, GoClaw includes the original message as context for the agent. In DMs, a `[From: sender_name]` annotation is prepended so the agent knows who sent the message.
### Streaming Cards
Real-time updates delivered as interactive card messages with animation:
```mermaid
flowchart TD
START["Agent starts responding"] --> CREATE["Create streaming card"]
CREATE --> SEND["Send card message
(streaming_mode: true)"]
SEND --> UPDATE["Update card text
with accumulated chunks
(throttled: 100ms min)"]
UPDATE -->|"More chunks"| UPDATE
UPDATE -->|"Done"| CLOSE["Close stream
(streaming_mode: false)"]
CLOSE --> FINAL["User sees full response"]
```
Updates throttled to prevent rate limiting. Display uses 50ms animation frequency (2-character steps).
### Media Handling
**Inbound**: Images, files, audio, video, stickers auto-downloaded and saved:
| Type | Extension |
|------|-----------|
| Image | `.png` |
| File | Original extension |
| Audio | `.opus` |
| Video | `.mp4` |
| Sticker | `.png` |
Max 30 MB by default (`media_max_mb`).
**Outbound**: Files auto-detected and uploaded with correct type (opus, mp4, pdf, doc, xls, ppt, or stream).
**Rich post messages**: GoClaw also extracts images embedded in Lark rich-text `post` messages (not only standalone image messages). Images within a post body are downloaded and included alongside other media in the inbound message context.
### @Mention Support
The bot sends native Lark @mentions in group messages. When the agent response contains `@open_id` patterns (e.g. `@ou_abc123`), they are automatically converted to native Lark `at` elements that trigger real notifications to the mentioned user. This works in both `post` text messages and interactive card messages.
### Mention Resolution
Larksuite sends placeholder tokens (e.g., `@_user_1`). Bot parses mention list and resolves to `@DisplayName`.
### Thread Session Isolation
When `topic_session_mode: "enabled"`, each thread gets isolated conversation:
```
Session key: "{chatID}:topic:{rootMessageID}"
```
Different threads in same group maintain separate histories.
### Slash Commands (File Writer Management)
In group chats, group members can manage file-write permissions using slash commands:
| Command | Description |
|---------|-------------|
| `/addwriter <@mention or reply>` | Grant file-write permission to a user in the group |
| `/removewriter <@mention or reply>` | Revoke file-write permission from a user |
| `/writers` | List all users with file-write permissions in the group |
**How to specify the target user:** Reply to the user's message and send the command, or @mention them in the same message. Self-grant is supported by @mentioning yourself.
**Authorization:** Only existing file writers can manage the list. When the list is empty, the first caller can seed it by specifying an explicit target.
> These commands work in group chats only. DMs are rejected.
### Lark Docx Auto-Fetch
When a Lark docx URL is pasted in chat, GoClaw automatically detects and fetches the document content via the Lark API and inlines it into the agent's prompt — no tool call required.
**Supported URL formats:**
- `https://*.feishu.cn/docx/`
- `https://*.larksuite.com/docx/`
**Required app permission scope:** `docx:document:readonly` — add this in your Larksuite Developer Console under Permissions & Scopes.
**Implementation details:**
- LRU cache: 128 entries, 5-minute TTL (repeated links in the same session are served from cache)
- Content truncated at 8,000 runes to fit the agent's context window
- Duplicate doc IDs in the same message are collapsed — each doc is fetched only once
> Only `/docx/` URLs are supported. Sheets, Base, Wiki, and other Lark document types are out of scope.
### list_group_members Tool
When connected to a Larksuite channel, agents have access to the `list_group_members` tool. It returns all members of the current group chat with their `open_id` and display name.
```
list_group_members(channel?, chat_id?) → { count, members: [{ member_id, name }] }
```
Use cases: checking who is in a group, identifying members before mentioning them, attendance tracking. To @mention a member in a reply, use `@member_id` (e.g. `@ou_abc123`) — the bot converts it to a native Lark mention with notification.
> This tool is only available on Feishu/Lark channels. It will not appear in the tool list for other channel types.
### Per-Topic Tool Allow List
Forum topics support their own tool whitelist. Configure under the agent's tool settings or channel metadata:
| Value | Behavior |
|-------|----------|
| `nil` (omit) | Inherit parent group's tool allow list |
| `[]` (empty) | No tools allowed in this topic |
| `["web_search", "group:fs"]` | Only these tools allowed |
The `group:fs` prefix selects all tools in the `fs` (Feishu/Lark) tool group. This follows the same `group:xxx` syntax used in Telegram topic config.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Invalid app credentials" | Check app_id and app_secret. Ensure app is published. |
| Webhook not receiving events | Verify webhook URL is publicly accessible. Check Larksuite Developer Console event subscriptions. |
| WebSocket keeps disconnecting | Check network. Verify app has `im:message` permission. |
| Streaming cards not updating | Ensure `streaming: true`. Check `render_mode` (auto/card). Messages shorter than limit render as plain text. |
| Media upload fails | Verify file type matches. Check file size under `media_max_mb`. |
| Mention not parsed | Ensure bot is mentioned. Check mention list in webhook payload. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Telegram](/channel-telegram) — Telegram bot setup
- [Zalo OA](/channel-zalo-oa) — Zalo Official Account
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Zalo OA Channel
Zalo Official Account (OA) integration. DM-only with pairing-based access control and image support.
## Setup
**Create Zalo OA:**
1. Go to https://oa.zalo.me
2. Create Official Account (requires Zalo phone number)
3. Set up OA name, avatar, and cover photo
4. In OA settings, go to "Settings" → "API" → "Bot API"
5. Create API key
6. Copy API key for configuration
**Enable Zalo OA:**
```json
{
"channels": {
"zalo": {
"enabled": true,
"token": "YOUR_API_KEY",
"dm_policy": "pairing",
"allow_from": [],
"media_max_mb": 5
}
}
}
```
## Configuration
All config keys are in `channels.zalo`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `token` | string | required | API key from Zalo OA console |
| `allow_from` | list | -- | User ID allowlist |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `webhook_url` | string | -- | Optional webhook URL (override polling) |
| `webhook_secret` | string | -- | Optional webhook signature secret |
| `media_max_mb` | int | 5 | Max image file size (MB) |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
## Features
### DM-Only
Zalo OA only supports direct messaging. Group functionality is not available. All messages are treated as DMs.
### Long Polling
Default mode: Bot polls Zalo API every 30 seconds for new messages. Server returns messages and marks them read.
- Poll timeout: 30 seconds (default)
- Error backoff: 5 seconds
- Text limit: 2,000 characters per message
- Image limit: 5 MB
### Webhook Mode (Optional)
Instead of polling, configure Zalo to POST events to your gateway:
```json
{
"webhook_url": "https://your-gateway.com/zalo/webhook",
"webhook_secret": "your_webhook_secret"
}
```
Zalo sends a HMAC signature in header `X-Zalo-Signature`. Implementation verifies this before processing.
### Image Support
Bot can receive and send images (JPG, PNG). Max 5 MB by default.
**Receive**: Images are downloaded and stored as temporary files during message processing.
**Send**: Images can be sent as media attachment:
```json
{
"channel": "zalo",
"content": "Here's your image",
"media": [
{ "url": "/tmp/image.jpg", "type": "image" }
]
}
```
### Pairing by Default
Default DM policy is `"pairing"`. New users see pairing code instructions with 60-second debounce (no spam). Owner approves via:
```
/pair CODE
```
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Invalid API key" | Check token from Zalo OA console. Ensure OA is active and Bot API enabled. |
| No messages received | Verify polling is running (check logs). Ensure OA can accept messages (not suspended). |
| Image upload fails | Verify image file exists and is under `media_max_mb`. Check file format (JPG/PNG). |
| Webhook signature mismatch | Ensure `webhook_secret` matches Zalo console. Check timestamp is recent. |
| Pairing codes not sent | Check DM policy is `"pairing"`. Verify owner can send messages to OA. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Zalo Personal](/channel-zalo-personal) — Personal Zalo account integration
- [Telegram](/channel-telegram) — Telegram bot setup
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Zalo Personal Channel
Unofficial personal Zalo account integration using reverse-engineered protocol (zcago). Supports DMs and groups with restrictive access control.
## Warning: Use at Your Own Risk
Zalo Personal uses an **unofficial, reverse-engineered protocol**. Your account may be locked, banned, or restricted by Zalo at any time. This is NOT recommended for production bots. Use [Zalo OA](/channel-zalo-oa) for official integrations.
A security warning is logged on startup: `security.unofficial_api`.
## Setup
**Prerequisites:**
- Personal Zalo account with credentials
- Credentials stored as JSON file
**Create Credentials JSON:**
```json
{
"phone": "84987654321",
"password": "your_password_here",
"device_id": "your_device_id"
}
```
**Enable Zalo Personal:**
```json
{
"channels": {
"zalo_personal": {
"enabled": true,
"credentials_path": "/home/goclaw/.goclaw/zalo-creds.json",
"dm_policy": "allowlist",
"group_policy": "allowlist",
"allow_from": ["friend_zalo_id", "group_chat_id"]
}
}
}
```
## Configuration
All config keys are in `channels.zalo_personal`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `credentials_path` | string | -- | Path to credentials JSON file |
| `allow_from` | list | -- | User/group ID allowlist |
| `dm_policy` | string | `"allowlist"` | `pairing`, `allowlist`, `open`, `disabled` (restrictive default) |
| `group_policy` | string | `"allowlist"` | `open`, `allowlist`, `disabled` (restrictive default) |
| `require_mention` | bool | true | Require bot mention in groups |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
## Features
### Comparison with Zalo OA
| Aspect | Zalo OA | Zalo Personal |
|--------|---------|---------------|
| Protocol | Official Bot API | Reverse-engineered (zcago) |
| Account type | Official Account | Personal account |
| DM support | Yes | Yes |
| Group support | No | Yes |
| Default DM policy | `pairing` | `allowlist` (restrictive) |
| Default group policy | N/A | `allowlist` (restrictive) |
| Auth method | API key | Credentials (phone + password) |
| Risk level | None | High (account may be banned) |
| Recommended for | Official bots | Development/testing only |
### DM & Group Support
Unlike Zalo OA, Personal supports both DMs and groups:
- DMs: Direct conversations with individual users
- Groups: Group chats (Zalo chat groups)
- Default policies are **restrictive**: `allowlist` for both DM and group
Explicitly allow users/groups via `allow_from`:
```json
{
"allow_from": [
"user_zalo_id_1",
"user_zalo_id_2",
"group_chat_id_3"
]
}
```
### Authentication
Requires credentials file with phone, password, and device ID. On first connection, account may require QR scan or additional verification from Zalo.
**QR re-authentication**: When re-authenticating via QR scan (e.g., after session expiry), GoClaw safely cancels the previous session before starting a new QR flow. This race-safe cancel prevents duplicate sessions from running simultaneously and avoids conflicting login attempts.
### Media Handling
Media sending includes post-write verification — files are confirmed written to disk before being sent to the Zalo API.
### Resilience
On connection failure:
- Max 10 restart attempts
- Exponential backoff: 1s → 60s max
- Special handling for error code 3000: 60s initial delay (usually rate limiting)
- Typing controller per thread (local key)
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Account locked" | Your account was restricted by Zalo. This happens frequently with bot integrations. Use Zalo OA instead. |
| "Invalid credentials" | Verify phone, password, and device ID in credentials file. Re-authenticate if Zalo requires verification. |
| No messages received | Check `allow_from` includes the sender. Verify DM/group policy is not `disabled`. |
| Bot keeps disconnecting | Zalo may be rate limiting. Check logs for error code 3000. Wait 60+ seconds before reconnecting. |
| "Unofficial API" warning | This is expected. Acknowledge the risk and use only for development/testing. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Zalo OA](/channel-zalo-oa) — Official Zalo integration (recommended)
- [Telegram](/channel-telegram) — Telegram bot setup
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Slack Channel
Slack integration via Socket Mode (WebSocket). Supports DMs, channel @mentions, threaded replies, streaming, reactions, media, and message debouncing.
## Setup
**Create a Slack App:**
1. Go to https://api.slack.com/apps?new_app=1
2. Select "From scratch", name your app (e.g., `GoClaw Bot`), pick workspace
3. Click **Create App**
**Enable Socket Mode:**
1. Left sidebar → **Socket Mode** → toggle ON
2. Name the token (e.g., `goclaw-socket`), add `connections:write` scope
3. Copy the **App-Level Token** (`xapp-...`)
**Add Bot Scopes:**
1. Left sidebar → **OAuth & Permissions**
2. Under **Bot Token Scopes**, add:
| Scope | Purpose |
|-------|---------|
| `app_mentions:read` | Receive @bot mention events |
| `chat:write` | Send and edit messages |
| `im:history` | Read DM messages |
| `im:read` | View DM channel list |
| `im:write` | Open DMs with users |
| `channels:history` | Read public channel messages |
| `groups:history` | Read private channel messages |
| `mpim:history` | Read multi-party DM messages |
| `reactions:write` | Add/remove emoji reactions (optional) |
| `reactions:read` | Read emoji reactions (optional) |
| `files:read` | Download files sent to bot |
| `files:write` | Upload files from agent |
| `users:read` | Resolve display names |
**Minimal set** (DM-only, no reactions/files): `chat:write`, `im:history`, `im:read`, `im:write`, `users:read`, `app_mentions:read`
**Enable Events:**
1. Left sidebar → **Event Subscriptions** → toggle ON
2. Under **Subscribe to bot events**, add:
| Event | Description |
|-------|-------------|
| `message.im` | Messages in DMs with the bot |
| `message.channels` | Messages in public channels |
| `message.groups` | Messages in private channels |
| `message.mpim` | Messages in multi-party DMs |
| `app_mention` | When bot is @mentioned |
No Request URL needed — Socket Mode handles events over WebSocket.
**Install & Get Token:**
1. **OAuth & Permissions** → **Install to Workspace** → **Allow**
2. Copy the **Bot User OAuth Token** (`xoxb-...`)
**Enable Slack in GoClaw:**
```json
{
"channels": {
"slack": {
"enabled": true,
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-LEVEL-TOKEN",
"dm_policy": "pairing",
"group_policy": "open",
"require_mention": true
}
}
}
```
Or via environment variables:
```bash
GOCLAW_SLACK_BOT_TOKEN=xoxb-...
GOCLAW_SLACK_APP_TOKEN=xapp-...
# Auto-enables Slack when both are set
```
**Invite Bot to Channels:**
- Public: `/invite @GoClaw Bot` in the channel
- Private: Channel name → **Integrations** → **Add an App**
- DMs: Message the bot directly
## Configuration
All config keys are in `channels.slack`:
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | false | Enable/disable channel |
| `bot_token` | string | required | Bot User OAuth Token (`xoxb-...`) |
| `app_token` | string | required | App-Level Token for Socket Mode (`xapp-...`) |
| `user_token` | string | -- | User OAuth Token for custom identity (`xoxp-...`) |
| `allow_from` | list | -- | User ID or channel ID allowlist |
| `dm_policy` | string | `"pairing"` | `pairing`, `allowlist`, `open`, `disabled` |
| `group_policy` | string | `"open"` | `open`, `pairing`, `allowlist`, `disabled` |
| `require_mention` | bool | true | Require @bot mention in channels |
| `history_limit` | int | 50 | Pending messages per channel for context (0=disabled) |
| `dm_stream` | bool | false | Enable streaming for DMs |
| `group_stream` | bool | false | Enable streaming for groups |
| `native_stream` | bool | false | Use Slack ChatStreamer API if available |
| `reaction_level` | string | `"off"` | `off`, `minimal`, `full` |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
| `debounce_delay` | int | 300 | Milliseconds before dispatching rapid messages (0=disabled) |
| `thread_ttl` | int | 24 | Hours before thread participation expires (0=disabled) |
| `media_max_bytes` | int | 20MB | Max file download size in bytes |
## Token Types
| Token | Prefix | Required | Purpose |
|-------|--------|----------|---------|
| Bot Token | `xoxb-` | Yes | Core API: messages, reactions, files, user info |
| App-Level Token | `xapp-` | Yes | Socket Mode WebSocket connection |
| User Token | `xoxp-` | No | Custom bot identity (username/icon override) |
Token prefix is validated on startup — misconfigured tokens fail fast with a clear error.
## Features
### Socket Mode
Uses WebSocket instead of HTTP webhooks. No public URL or ingress required — ideal for self-hosted deployments. Events are acknowledged within 3 seconds per Slack requirements.
Dead socket classification detects non-retryable auth errors (`invalid_auth`, `token_revoked`, `missing_scope`) and stops the channel instead of retrying infinitely.
### Mention Gating
In channels, the bot responds only when @mentioned (default `require_mention: true`). Unmentioned messages are stored in a pending history buffer and included as context when the bot is next mentioned.
```mermaid
flowchart TD
MSG["User posts in channel"] --> MENTION{"Bot @mentioned
or in participated thread?"}
MENTION -->|No| BUFFER["Add to pending history
(max 50 messages)"]
MENTION -->|Yes| PROCESS["Process now
Include history as context"]
BUFFER --> NEXT["Next mention:
history included"]
```
When `require_mention: false`, Slack delivers both a `message` event and an `app_mention` event for the same message. GoClaw uses a shared dedup key (`channel:timestamp`) so whichever event arrives first processes the message; the duplicate is dropped. With `require_mention: false`, the `app_mention` handler exits before storing the dedup key, ensuring the `message` handler takes ownership.
### Thread Participation
After the bot replies in a thread, it auto-replies to subsequent messages in that thread without requiring @mention. Participation expires after `thread_ttl` hours (default 24). Set `thread_ttl: 0` to disable (always require @mention).
### Message Debouncing
Rapid messages from the same thread are batched into a single dispatch. Default delay: 300ms (configurable via `debounce_delay`). Pending batches are flushed on shutdown.
On top of this channel-level batching, the gateway-wide [Inbound Debounce](/channels-overview#inbound-debounce) also applies — it merges rapid messages (and multi-file uploads) from the same sender before the agent runs.
### Message Formatting
LLM markdown output is converted to Slack mrkdwn:
```
Markdown → Slack mrkdwn
**bold** → *bold*
_italic_ → _italic_
~~strike~~ → ~strike~
# Header → *Header*
[text](url) →
```
Tables render as code blocks. Slack-native tokens (`<@U123>`, `<#C456>`, URLs) are preserved through the conversion pipeline. Messages exceeding 4,000 characters are split at newline boundaries.
### Streaming
Enable live response updates via `chat.update` (edit-in-place):
- **DMs** (`dm_stream`): Edits the "Thinking..." placeholder as chunks arrive
- **Groups** (`group_stream`): Same behavior, within threads
Updates are throttled to 1 edit per second to avoid Slack rate limits. Set `native_stream: true` to use Slack's ChatStreamer API when available.
### Reactions
Show emoji status on user messages. Set `reaction_level`:
- `off` — No reactions (default)
- `minimal` — Only thinking and done
- `full` — All statuses: thinking, tool use, done, error, stall
| Status | Emoji |
|--------|-------|
| Thinking | :thinking_face: |
| Tool use | :hammer_and_wrench: |
| Done | :white_check_mark: |
| Error | :x: |
| Stall | :hourglass_flowing_sand: |
Reactions are debounced at 700ms to prevent API spam.
### Media Handling
**Receiving files:** Files attached to messages are downloaded with SSRF protection (hostname allowlist: `*.slack.com`, `*.slack-edge.com`, `*.slack-files.com`). Auth tokens are stripped on redirect. Files exceeding `media_max_bytes` (default 20MB) are skipped.
**Sending files:** Agent-generated files are uploaded via Slack's file upload API. Failed uploads show an inline error message. When the agent sends a batch (`send_file` with `attachments[]`), Slack uses an **ordered single-send** fallback — files are delivered one after another in order. See [Multi-Attachment Delivery](/channels-overview#multi-attachment-delivery-batching).
**Document extraction:** Document files (PDFs, text files) have their content extracted and appended to the message for the agent to process.
### Custom Bot Identity
With an optional User Token (`xoxp-`), the bot can post with a custom username and icon:
1. In **OAuth & Permissions** → **User Token Scopes** → add `chat:write.customize`
2. Re-install the app
3. Add `user_token` to config
### Group Policy: Pairing
Slack supports group-level pairing. When `group_policy: "pairing"`:
- Admin approves channels via CLI: `goclaw pairing approve `
- Or via the GoClaw web UI (Pairing section)
- Pairing codes for groups are **not** shown in the channel (security: visible to all members)
The `allow_from` list supports both user IDs and Slack channel IDs for group-level allowlisting.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| `invalid_auth` on startup | Wrong token or revoked. Re-generate token in Slack app settings. |
| `missing_scope` error | Required scope not added. Add scope in OAuth & Permissions, reinstall app. |
| Bot doesn't respond in channel | Bot not invited to channel. Run `/invite @BotName`. |
| Bot doesn't respond in DM | DM policy is `disabled` or pairing required. Check `dm_policy` config. |
| Socket Mode won't connect | App-Level Token (`xapp-`) missing or incorrect. Check Basic Information page. |
| Bot responds without custom name | User Token not configured. Add `user_token` with `chat:write.customize` scope. |
| Messages processed twice | Socket Mode reconnect dedup is built-in. If persists, check for duplicate app_mention + message events — normal behavior, dedup handles it. |
| Rapid messages sent separately | Increase `debounce_delay` (default 300ms). |
| Thread auto-reply stopped | Thread participation expired (`thread_ttl`, default 24h). Mention bot again. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Telegram](/channel-telegram) — Telegram bot setup
- [Discord](/channel-discord) — Discord bot setup
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# WhatsApp Channel
Direct WhatsApp integration. GoClaw connects directly to WhatsApp's multi-device protocol — no external bridge or Node.js service required. Auth state is stored in the database (PostgreSQL or SQLite).
## Setup
1. **Channels > Add Channel > WhatsApp**
2. Choose an agent, click **Create & Scan QR**
3. Scan the QR code with WhatsApp (You > Linked Devices > Link a Device)
4. Configure DM/group policies as needed
That's it — no bridge to deploy, no extra containers.
### Config File Setup
For config-file-based channels (instead of DB instances):
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"dm_policy": "pairing",
"group_policy": "pairing"
}
}
}
```
## Configuration
All config keys are in `channels.whatsapp` (config file) or the instance config JSON (DB):
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `enabled` | bool | `false` | Enable/disable channel |
| `allow_from` | list | -- | User/group ID allowlist |
| `dm_policy` | string | `"pairing"` | `pairing`, `open`, `allowlist`, `disabled` |
| `group_policy` | string | `"pairing"` (DB) / `"open"` (config) | `pairing`, `open`, `allowlist`, `disabled` |
| `require_mention` | bool | `false` | Only respond in groups when bot is @mentioned |
| `history_limit` | int | `200` | Max pending group messages for context (0=disabled) |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
## Architecture
```mermaid
flowchart LR
WA["WhatsApp
Servers"]
GC["GoClaw"]
UI["Web UI
(QR Wizard)"]
WA <-->|"Multi-device protocol"| GC
GC -->|"QR events via WS"| UI
```
- **GoClaw** connects directly to WhatsApp servers via multi-device protocol
- Auth state is stored in the database — survives restarts
- One channel instance = one WhatsApp phone number
- No bridge, no Node.js, no shared volumes
## Features
### QR Code Authentication
WhatsApp requires QR code scanning to link a device. The flow:
1. GoClaw generates QR code for device linking
2. QR string is encoded as PNG (base64) and sent to the UI wizard via WS event
3. Web UI displays the QR image
4. User scans with WhatsApp (You > Linked Devices > Link a Device)
5. Connection confirmed via auth event
**Re-authentication**: Use the "Re-authenticate" button in the channels table to force a new QR scan (logs out the current WhatsApp session and deletes stored device credentials).
### DM and Group Policies
WhatsApp groups have chat IDs ending in `@g.us`:
- **DM**: `"1234567890@s.whatsapp.net"`
- **Group**: `"120363012345@g.us"`
Available policies:
| Policy | Behavior |
|--------|----------|
| `open` | Accept all messages |
| `pairing` | Require pairing code approval (default for DB instances) |
| `allowlist` | Only users in `allow_from` |
| `disabled` | Reject all messages |
Group `pairing` policy: unpaired groups receive a pairing code reply. Approve via `goclaw pairing approve `.
### @Mention Gating
When `require_mention` is `true`, the bot only responds in group chats when explicitly @mentioned. Unmentioned messages are recorded for context — when the bot is mentioned, recent group history is prepended to the message.
Fails closed — if the bot's JID is unknown, messages are ignored.
### Media Support
GoClaw downloads incoming media directly (images, video, audio, documents, stickers) to temporary files, then passes them to the agent pipeline.
Supported inbound media types: image, video, audio, document, sticker (max 20 MB each).
Outbound media: GoClaw uploads files to WhatsApp's servers with proper encryption. Supports image, video, audio, and document types with captions.
### Message Formatting
LLM output is converted from Markdown to WhatsApp's native formatting:
| Markdown | WhatsApp | Rendered |
|----------|----------|----------|
| `**bold**` | `*bold*` | **bold** |
| `_italic_` | `_italic_` | _italic_ |
| `~~strikethrough~~` | `~strikethrough~` | ~~strikethrough~~ |
| `` `inline code` `` | `` `inline code` `` | `code` |
| `# Header` | `*Header*` | **Header** |
| `[text](url)` | `text url` | text url |
| `- list item` | `• list item` | • list item |
Fenced code blocks are preserved as ` ``` `. HTML tags from LLM output are pre-processed to Markdown equivalents before conversion. Long messages are automatically chunked at ~4096 characters, splitting at paragraph or line boundaries.
### Typing Indicators
GoClaw shows "typing..." in WhatsApp while the agent processes a message. WhatsApp clears the indicator after ~10 seconds, so GoClaw refreshes every 8 seconds until the reply is sent.
### Auto-Reconnect
Reconnection is handled automatically. If the connection drops:
- Built-in reconnect logic handles retry with exponential backoff
- Channel health status updated (degraded → healthy on reconnect)
- No manual reconnect loop needed
### LID Addressing
WhatsApp uses dual identity: phone JID (`@s.whatsapp.net`) and LID (`@lid`). Groups may use LID addressing. GoClaw normalizes to phone JID for consistent policy checks, pairing lookups, and allowlists.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| No QR code appears | Check GoClaw logs. Ensure the server can reach WhatsApp servers (ports 443, 5222). |
| QR scanned but no auth | Auth state may be corrupted. Use "Re-authenticate" button or restart the channel. |
| Messages not received | Check `dm_policy` and `group_policy`. If `pairing`, the user/group needs approval via `goclaw pairing approve`. |
| Media not received | Check GoClaw logs for "media download failed". Ensure temp directory is writable. Max 20 MB per file. |
| Typing indicator stuck | GoClaw auto-cancels typing when reply is sent. If stuck, WhatsApp connection may have dropped — check channel health. |
| Group messages ignored | Check `group_policy`. If `pairing`, the group needs approval. If `require_mention` is true, @mention the bot. |
| "logged out" in logs | WhatsApp revoked the session. Use "Re-authenticate" button to scan a new QR code. |
| `bridge_url` error on startup | `bridge_url` is no longer supported. WhatsApp now runs natively — remove `bridge_url` from config/credentials. |
## Migrating from Bridge
If you previously used the Baileys bridge (`bridge_url` config):
1. Remove `bridge_url` from your channel config or credentials
2. Remove/stop the bridge container (no longer needed)
3. Delete the bridge shared volume (`wa_media`)
4. Re-authenticate via QR scan in the UI (existing bridge auth state is not compatible)
GoClaw will detect old `bridge_url` config and show a clear migration error.
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Telegram](/channel-telegram) — Telegram bot setup
- [Larksuite](/channel-feishu) — Larksuite integration
- [Browser Pairing](/channel-browser-pairing) — Pairing flow
---
# Pancake Channel
Unified multi-platform channel proxy powered by Pancake (pages.fm). A single Pancake API key gives access to Facebook, Zalo OA, Instagram, TikTok, WhatsApp, and Line — no per-platform OAuth required.
## What is Pancake?
Pancake is a social commerce platform that provides a unified messaging proxy across multiple social networks. Instead of integrating with each platform's API individually, GoClaw connects to Pancake once and reaches users on all connected platforms through a single channel instance.
## Supported Platforms
| Platform | Max Message Length | Formatting |
|----------|-------------------|------------|
| Facebook | 2,000 | Plain text (strips markdown) |
| Zalo OA | 2,000 | Plain text (strips markdown) |
| Instagram | 1,000 | Plain text (strips markdown) |
| TikTok | 500 | Plain text, truncated at 500 chars |
| Shopee | 500 | Plain text, truncated at 500 chars |
| WhatsApp | 4,096 | WhatsApp-native (*bold*, _italic_) |
| Line | 5,000 | Plain text (strips markdown) |
## Setup
### Pancake-side Setup
1. Create a Pancake account at [pages.fm](https://pages.fm)
2. Connect your social pages (Facebook, Zalo OA, etc.) to Pancake
3. Generate a Pancake API key from your account settings
4. Note your Page ID from the Pancake dashboard
### GoClaw-side Setup
1. **Channels > Add Channel > Pancake**
2. Enter your credentials:
- **API Key**: Your Pancake user-level API key
- **Page Access Token**: Page-level token for all page APIs
- **Page ID**: The Pancake page identifier
3. Set a **Webhook Secret** — required for webhook delivery; GoClaw rejects all webhook events when this is not configured
4. Configure platform-specific features (inbox reply, comment reply)
That's it — one channel serves all platforms connected to that Pancake page.
### Config File Setup
For config-file-based channels (instead of DB instances):
```json
{
"channels": {
"pancake": {
"enabled": true,
"instances": [
{
"name": "my-facebook-page",
"credentials": {
"api_key": "your_pancake_api_key",
"page_access_token": "your_page_access_token",
"webhook_secret": "optional_hmac_secret"
},
"config": {
"page_id": "your_page_id",
"features": {
"inbox_reply": true,
"comment_reply": true,
"private_reply": false,
"first_inbox": true,
"auto_react": false
},
"private_reply_message": "Thanks {{commenter_name}} for your comment! We'll DM you shortly.",
"comment_reply_options": {
"include_post_context": true,
"filter": "all"
}
}
}
]
}
}
}
```
## Configuration
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `api_key` | string | -- | User-level Pancake API key (required) |
| `page_access_token` | string | -- | Page-level token for all page APIs (required) |
| `webhook_secret` | string | -- | HMAC-SHA256 webhook secret — **required** for webhook delivery; events are rejected without it |
| `page_id` | string | -- | Pancake page identifier (required) |
| `webhook_page_id` | string | -- | Native platform page ID sent in webhooks (if different from `page_id`) |
| `platform` | string | auto-detected | Platform override: facebook/zalo/instagram/tiktok/shopee/whatsapp/line |
| `features.inbox_reply` | bool | -- | Enable inbox message replies |
| `features.comment_reply` | bool | -- | Enable comment replies |
| `features.private_reply` | bool | -- | Send a one-time DM to a commenter after each comment reply (stateless, no DB required) |
| `features.auto_react` | bool | -- | Auto-like user comments on Facebook (Facebook only) |
| `auto_react_options.allow_post_ids` | list | -- | Only react to comments on these post IDs (nil = all posts) |
| `auto_react_options.deny_post_ids` | list | -- | Never react to comments on these post IDs (overrides allow) |
| `auto_react_options.allow_user_ids` | list | -- | Only react to comments from these user IDs (nil = all users) |
| `auto_react_options.deny_user_ids` | list | -- | Never react to comments from these user IDs (overrides allow) |
| `comment_reply_options.include_post_context` | bool | false | Prepend post text to comment content sent to the agent |
| `comment_reply_options.filter` | string | `"all"` | Comment filter mode: `"all"` or `"keyword"` |
| `comment_reply_options.keywords` | list | -- | Required when `filter="keyword"` — only process comments containing these keywords |
| `private_reply_message` | string | built-in EN | Template DM for `features.private_reply`. Supports `{{commenter_name}}` and `{{post_title}}` variables. Falls back to a built-in English message if empty. |
| `first_inbox_message` | string | built-in | Custom DM text sent for first-inbox feature |
| `post_context_cache_ttl` | string | `"15m"` | Cache TTL for post content fetched for comment context (e.g. `"30m"`) |
| `block_reply` | bool | -- | Override gateway block_reply (nil=inherit) |
| `chat_behavior` | object | -- | Override gateway [human-like delivery](/channels-overview#human-like-delivery) for this channel (nil = inherit) |
| `allow_from` | list | -- | User/group ID allowlist |
## Architecture
```mermaid
flowchart LR
FB["Facebook"]
ZA["Zalo OA"]
IG["Instagram"]
TK["TikTok"]
SP["Shopee"]
WA["WhatsApp"]
LN["Line"]
PC["Pancake Proxy
(pages.fm)"]
GC["GoClaw"]
FB --> PC
ZA --> PC
IG --> PC
TK --> PC
SP --> PC
WA --> PC
LN --> PC
PC <-->|"Webhook + REST API"| GC
```
- **One channel instance = one Pancake page** (serving multiple platforms)
- **Platform auto-detected** at Start() from Pancake page metadata
- **Webhook-based** — no polling, Pancake servers push events to GoClaw
- A single HTTP handler at `/channels/pancake/webhook` routes to the correct channel by page_id
## Features
### Multi-Platform Support
One Pancake channel instance can serve multiple platforms simultaneously. The platform is determined by the Pancake page metadata:
- At Start(), GoClaw calls `GET /pages` to list all pages and match the configured page_id
- The `platform` field (facebook/zalo/instagram/tiktok/shopee/whatsapp/line) is extracted from page metadata
- If platform is not configured or detection fails, defaults to "facebook" with 2,000 char limit
### Webhook Delivery
Pancake uses webhook push (not polling) for message delivery:
- GoClaw registers a single route: `POST /channels/pancake/webhook`
- All Pancake page webhooks route through one handler, dispatched by `page_id`
- Always returns HTTP 200 — Pancake suspends webhooks if >80% errors in a 30-min window
- Request body is capped at 1 MB to prevent abuse
**HMAC signature verification (required):** GoClaw verifies every incoming webhook using HMAC-SHA256. The `webhook_secret` credential must be set — without it, all webhook deliveries are rejected (HTTP 200 returned but event is dropped). Configure the same secret in your Pancake dashboard webhook settings.
Pancake sends the signature in the `X-Pancake-Signature` request header with format `sha256=`. GoClaw recomputes `HMAC-SHA256(body, webhook_secret)` and compares using a constant-time comparison to prevent timing attacks.
**Replay protection:** After signature verification passes, GoClaw computes `SHA-256(body)` and stores it in an in-memory dedup map with a 24-hour TTL. Duplicate deliveries (same raw body) are silently dropped before any message processing occurs.
> **Security note:** Enabling `features.auto_react` without a `webhook_secret` configured causes GoClaw to log a warning at startup (`security.pancake_auto_react_without_hmac`). Any actor who can reach the webhook endpoint could then trigger comment-like calls without authentication.
Webhook payload structure:
```json
{
"event_type": "messaging",
"page_id": "your_page_id",
"data": {
"conversation": {
"id": "pageID_senderID",
"type": "INBOX",
"from": { "id": "sender_id", "name": "Sender Name" },
"assignee_ids": ["staff_id_1"]
},
"message": {
"id": "msg_unique_id",
"message": "Hello from customer",
"attachments": [{ "type": "image", "url": "https://..." }]
}
}
}
```
Only `INBOX` conversation events are processed. `COMMENT` events are skipped unless `comment_reply` is enabled.
#### Shopee Webhooks
Shopee uses a distinct conversation ID format: `spo_{page_numeric}_{sender_id}`. GoClaw automatically detects the `spo_` prefix and parses the `page_id` as `spo_{page_numeric}`:
```json
{
"event_type": "messaging",
"data": {
"conversation": {
"id": "spo_25409726_109139680425439630",
"type": "INBOX",
"from": { "id": "109139680425439630", "name": "Test Buyer" }
},
"message": {
"id": "spo_msg_1",
"content": "Shop oi con hang khong?"
}
}
}
```
Shopee deduplication operates at webhook-level (same as TikTok) — based on `message_id` in the payload, no DB state required.
### Message Deduplication
Pancake uses at-least-once delivery, so duplicate webhook deliveries are expected:
- **Message dedup**: `sync.Map` keyed by `msg:{message_id}` with 24-hour TTL (inbox) or `comment:{message_id}` (comment)
- **Outbound echo detection**: Pre-stores message fingerprints before sending, suppresses webhook echoes of our own replies (45-second TTL)
- Background cleaner evicts stale entries every 5 minutes to prevent memory growth
- Messages missing `message_id` skip dedup (prevents shared slot collisions)
- **TikTok and Shopee**: webhook-level dedup; no additional DB state required
### Reply Loop Prevention
Multiple guards prevent the bot from responding to its own messages:
1. **Page self-message filter**: Skips messages where `sender_id == page_id`
2. **Staff assignee filter**: Skips messages from Pancake staff assigned to the conversation
3. **Outbound echo detection**: Matches inbound content against recently sent messages
### Media Support
**Inbound media**: Attachments arrive as URLs in the webhook payload. GoClaw includes them directly in the message content passed to the agent pipeline.
**Outbound media**: Files are uploaded via `POST /pages/{id}/upload_contents` (multipart/form-data), then sent as `content_ids` in a separate API call. Media and text are delivered sequentially:
1. Upload media files, collect attachment IDs
2. Send attachment message with content_ids
3. Follow with text message (if any)
If media upload fails, the text portion is sent anyway with a warning logged. Media paths must be absolute to prevent directory traversal.
### Message Formatting
LLM output is converted from Markdown to platform-appropriate formatting:
| Platform | Behavior |
|----------|----------|
| Facebook | Strips markdown, keeps plain text (Messenger doesn't support rich formatting) |
| WhatsApp | Converts `**bold**` to `*bold*`, `_italic_` preserved, headers stripped |
| TikTok | Strips markdown + truncates to 500 runes |
| Shopee | Strips markdown + truncates to 500 runes (same as TikTok) |
| Instagram / Zalo / Line | Strips all markdown, returns plain text |
Long messages are automatically split into chunks respecting each platform's character limit. Rune-based splitting (not byte-based) ensures multi-byte characters (CJK, Vietnamese, emoji) are not corrupted.
### Inbox vs Comment Modes
Pancake supports two conversation types:
- **INBOX**: Direct messages from users (default, always processed)
- **COMMENT**: Comments on social posts (controlled by `comment_reply` feature flag)
Conversation type is stored in message metadata as `pancake_mode` ("inbox" or "comment"), enabling agents to respond differently based on the source.
### Comment Features
When `features.comment_reply: true`, additional options control comment handling:
**Comment filter** (`comment_reply_options.filter`):
- `"all"` (default) — process all comments
- `"keyword"` — only process comments containing one of the configured `keywords`
**Post context** (`comment_reply_options.include_post_context: true`): fetches the original post text and prepends it to the comment content before sending to the agent. Useful when comments are too short to understand without context. Post content is cached (default TTL: 15 minutes, configurable via `post_context_cache_ttl`).
**Auto-react** (`features.auto_react: true`): automatically likes every valid incoming comment on Facebook (Facebook platform only). Fires independently of `comment_reply` — you can react without replying.
Scope the reactions further with `auto_react_options`:
| Field | Type | Behavior |
|-------|------|----------|
| `allow_post_ids` | list | React only on comments for these post IDs (nil = all posts) |
| `deny_post_ids` | list | Never react on these post IDs (overrides allow) |
| `allow_user_ids` | list | React only to comments from these user IDs (nil = all users) |
| `deny_user_ids` | list | Never react to comments from these user IDs (overrides allow) |
Deny lists always take precedence over allow lists. Omitting `auto_react_options` entirely means no scope filter (react to all valid comments).
**First inbox** (`features.first_inbox: true`): after replying to a comment, sends a one-time welcome DM to the commenter via the first-inbox flow. Only sent once per sender per session restart. Customize the DM text with `first_inbox_message`.
### Private Reply (Stateless DM)
`features.private_reply: true` sends a private DM to the commenter immediately after a public comment reply — no DB table or in-memory state required.
**Idempotency mechanism**: Relies on webhook-level comment dedup (above) and Facebook's per-comment `private_replies` endpoint — Facebook returns an error if a DM was already sent for that comment, and GoClaw logs a warning and continues.
**Template message**: Configured via `private_reply_message` with these variables:
| Variable | Content |
|----------|---------|
| `{{commenter_name}}` | Commenter's display name (sanitized) |
| `{{post_title}}` | Associated post content (fetched from post cache) |
Variables are substituted literally — values are pre-sanitized (stripping `{{` and `}}`) to prevent template injection. If `private_reply_message` is empty, the built-in default is used: `"Thanks for your comment! We'll DM you shortly."`
**How private_reply differs from first_inbox:**
| | `private_reply` | `first_inbox` |
|-|----------------|--------------|
| Trigger | Every comment reply | First time per user (per restart) |
| Idempotency | FB API + webhook dedup (stateless) | In-memory set per restart |
| Config key | `private_reply_message` | `first_inbox_message` |
### Channel Health
API errors are mapped to channel health states:
| Error Type | HTTP Codes | Health State |
|------------|-----------|--------------|
| Auth failure | 401, 403, 4001, 4003 | Failed (token expired or invalid) |
| Rate limited | 429, 4029 | Degraded (recoverable) |
| Unknown API error | Others | Degraded (recoverable) |
Application-level failures (HTTP 200 with `success: false` in JSON body) are also detected and treated as send errors.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "api_key is required" on startup | Add `api_key` to credentials. Get it from your Pancake account settings. |
| "page_access_token is required" | Add `page_access_token` to credentials. This is the page-level token from Pancake. |
| "page_id is required" | Add `page_id` to config. Find it in your Pancake dashboard URL. |
| Token verification failed | The `page_access_token` may be expired or invalid. Regenerate from Pancake dashboard. |
| No messages received | Check Pancake webhook URL is configured: `https://your-goclaw-host/channels/pancake/webhook`. |
| Webhook signature mismatch / events not processed | Verify `webhook_secret` is set and matches the secret configured in the Pancake dashboard. Events are silently dropped when `webhook_secret` is empty. |
| "no channel instance for page_id" | The `page_id` in the webhook doesn't match any registered channel. Check config. |
| Platform shows as unknown | `platform` is auto-detected. Ensure the page is connected in Pancake. Can override manually. |
| Media upload fails | Media paths must be absolute. Check file exists and is readable. |
| Messages appear duplicated | This is normal — dedup handles it. If persistent, check Pancake webhook config isn't double-registered. |
## What's Next
- [Channel Overview](/channels-overview) — Channel concepts and policies
- [WhatsApp](/channel-whatsapp) — Direct WhatsApp integration
- [Telegram](/channel-telegram) — Telegram bot setup
- [Multi-Channel Setup](/recipe-multi-channel) — Configure multiple channels
---
# Facebook Channel
Facebook Fanpage integration supporting Messenger inbox auto-reply, comment auto-reply, and first inbox DM via Facebook Graph API.
## Setup
### 1. Create a Facebook App
1. Go to [developers.facebook.com](https://developers.facebook.com) and create a new app
2. Choose **Business** type
3. Add the **Messenger** and **Webhooks** products
4. Under **Messenger Settings** → **Access Tokens** → generate a Page Access Token for your page
5. Copy your **App ID**, **App Secret**, and **Page Access Token**
6. Note your **Facebook Page ID** (visible in your page's About section or URL)
### 2. Configure the Webhook
In your Facebook App Dashboard → **Webhooks** → **Page**:
1. Set the callback URL: `https://your-goclaw-host/channels/facebook/webhook`
2. Set a verify token (any string you choose — use this as `verify_token` in GoClaw config)
3. Subscribe to these events: `messages`, `messaging_postbacks`, `feed`
### 3. Enable Facebook Channel
```json
{
"channels": {
"facebook": {
"enabled": true,
"instances": [
{
"name": "my-fanpage",
"credentials": {
"page_access_token": "YOUR_PAGE_ACCESS_TOKEN",
"app_secret": "YOUR_APP_SECRET",
"verify_token": "YOUR_VERIFY_TOKEN"
},
"config": {
"page_id": "YOUR_PAGE_ID",
"features": {
"messenger_auto_reply": true,
"comment_reply": false,
"first_inbox": false
}
}
}
]
}
}
}
```
## Configuration
### Credentials (encrypted)
| Key | Type | Description |
|-----|------|-------------|
| `page_access_token` | string | Page-level token from Facebook App Dashboard (required) |
| `app_secret` | string | App Secret for webhook signature verification (required) |
| `verify_token` | string | Token used to verify webhook endpoint ownership (required) |
### Instance Config
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `page_id` | string | required | Facebook Page ID |
| `features.messenger_auto_reply` | bool | false | Enable Messenger inbox auto-reply |
| `features.comment_reply` | bool | false | Enable comment auto-reply |
| `features.first_inbox` | bool | false | Send a one-time DM after first comment reply |
| `comment_reply_options.include_post_context` | bool | false | Fetch post content to enrich comment context |
| `comment_reply_options.max_thread_depth` | int | 10 | Max depth for fetching parent comment threads |
| `messenger_options.session_timeout` | string | -- | Override session timeout for Messenger conversations (e.g. `"30m"`) |
| `post_context_cache_ttl` | string | -- | Cache TTL for post content fetches (e.g. `"10m"`) |
| `first_inbox_message` | string | -- | Custom DM text sent after first comment reply (defaults to Vietnamese if empty) |
| `allow_from` | list | -- | Sender ID allowlist |
## Architecture
```mermaid
flowchart TD
FB_USER["Facebook User"]
FB_PAGE["Facebook Page"]
WEBHOOK["GoClaw Webhook\n/channels/facebook/webhook"]
ROUTER["Global Router\n(routes by page_id)"]
CH["Channel Instance"]
AGENT["Agent Pipeline"]
GRAPH["Graph API\ngraph.facebook.com"]
FB_USER -->|"Comment / Message"| FB_PAGE
FB_PAGE -->|"Webhook event (POST)"| WEBHOOK
WEBHOOK -->|"Verify HMAC-SHA256"| ROUTER
ROUTER --> CH
CH -->|"HandleMessage"| AGENT
AGENT -->|"OutboundMessage"| CH
CH -->|"Send reply"| GRAPH
GRAPH --> FB_PAGE
```
- **Single webhook endpoint** — all Facebook channel instances share `/channels/facebook/webhook`, routed by `page_id`
- **HMAC-SHA256 verification** — every webhook delivery is verified against `app_secret` via `X-Hub-Signature-256` header
- **Graph API v25.0** — all outbound calls use the versioned Graph API endpoint
## Features
### fb_mode: Page Mode vs Comment Mode
The `fb_mode` metadata field controls how the agent's reply is delivered:
| `fb_mode` | Trigger | Reply method |
|-----------|---------|--------------|
| `messenger` | Messenger inbox message | `POST /me/messages` to the sender |
| `comment` | Comment on a page post | `POST /{comment_id}/comments` reply |
The channel sets `fb_mode` automatically based on the event type. Agents can read this metadata to tailor their response style.
### Messenger Auto-Reply
When `features.messenger_auto_reply` is enabled:
- Responds to text messages and postbacks from users in Messenger
- Session key is `senderID` (1:1 channel-scoped conversations)
- Skips delivery/read receipts and attachment-only messages
- Long responses are automatically split at 2,000 characters
### Comment Auto-Reply
When `features.comment_reply` is enabled:
- Responds to new comments on the page's posts (`verb: "add"`)
- Ignores comment edits and deletions
- Session key: `{post_id}:{sender_id}` — groups all comments from the same user on the same post
- Optional: fetches post content and parent comment thread for richer context (see `comment_reply_options`)
### Admin Reply Detection
GoClaw automatically detects when a human page admin replies to a conversation and suppresses the bot's auto-reply for a **5-minute cooldown window**. This prevents the bot from sending a duplicate message after the admin has already responded.
Detection logic:
1. When a message from `sender_id == page_id` arrives, GoClaw records the recipient as admin-replied
2. Bot echo detection: if the bot itself just sent a message within a 15-second window, the "admin reply" is ignored (it's the bot's own echo)
3. Cooldown expires after 5 minutes — auto-reply resumes
### First Inbox DM
When `features.first_inbox` is enabled, GoClaw sends a one-time private Messenger DM to a user after the bot first replies to their comment:
- Sent at most once per user per process lifetime (in-memory dedup)
- Customize the message with `first_inbox_message`; defaults to Vietnamese if empty
- Best-effort: send failures are logged and retried on next comment
### Webhook Setup
The webhook handler:
1. **GET** — Verifies ownership by echoing `hub.challenge` when `hub.verify_token` matches
2. **POST** — Processes event delivery:
- Validates `X-Hub-Signature-256` HMAC-SHA256 signature
- Parses `feed` changes for comment events
- Parses `messaging` events for Messenger events
- Always returns HTTP 200 (non-2xx causes Facebook to retry for 24 hours)
Body size is capped at 4 MB. Oversized payloads are dropped with a warning.
### Message Deduplication
Facebook may deliver the same webhook event more than once. GoClaw deduplicates by event key:
- Messenger: `msg:{message_mid}`
- Postback: `postback:{sender_id}:{timestamp}:{payload}`
- Comment: `comment:{comment_id}`
Dedup entries expire after 24 hours (matching Facebook's max retry window). A background cleaner evicts stale entries every 5 minutes.
### Graph API
All outbound calls go through `graph.facebook.com/v25.0` with automatic retry:
- **3 retries** with exponential backoff (1s, 2s, 4s)
- **Rate limit handling**: parses `X-Business-Use-Case-Usage` header and respects `Retry-After`
- **Token passed via `Authorization: Bearer` header** (never in URL)
- **24h messaging window**: code 551 / subcode 2018109 are non-retryable (user has not messaged in 24h)
### Media Support
**Inbound** (Messenger): Attachment URLs are included in the message metadata. Types: `image`, `video`, `audio`, `file`.
**Outbound**: Text replies only. Media delivery from the agent is not currently supported for the native Facebook channel. Use [Pancake](/channel-pancake) for full media support across Facebook and other platforms.
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Webhook verification fails | Check `verify_token` in GoClaw matches the token in Facebook App Dashboard. |
| `page_access_token is required` | Add `page_access_token` to credentials. |
| `page_id is required` | Add `page_id` to instance config. |
| Token verification failed on start | The `page_access_token` may be expired. Regenerate from Facebook App Dashboard. |
| No events received | Ensure webhook callback URL is publicly accessible. Check Facebook App → Webhooks subscriptions (`messages`, `feed`). |
| Signature invalid warnings | Ensure `app_secret` in GoClaw matches the App Secret in Facebook App Dashboard. |
| Bot replies after admin already responded | Expected — bot suppresses for 5 min after admin reply. Set `features.messenger_auto_reply: false` to disable entirely. |
| 24h messaging window error | The user hasn't sent a message in the last 24 hours. Facebook restricts bot-initiated messages outside this window. |
| Duplicate messages | Dedup handles this automatically. If persistent, check for multiple GoClaw instances with the same `page_id`. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [Pancake](/channel-pancake) — Multi-platform proxy (Facebook + Zalo + Instagram + more)
- [Zalo OA](/channel-zalo-oa) — Zalo Official Account
- [Telegram](/channel-telegram) — Telegram bot setup
---
# WebSocket Channel
Direct RPC communication with the GoClaw gateway over WebSocket. No intermediate messaging platform needed—perfect for custom clients, web apps, and testing.
## Connection
**Endpoint:**
```
ws://your-gateway.com:8080/ws
wss://your-gateway.com:8080/ws (TLS)
```
**WebSocket Upgrade:**
```
GET /ws HTTP/1.1
Host: your-gateway.com:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13
```
Server responds with `101 Switching Protocols`.
## Authentication
First message must be a `connect` frame:
```json
{
"type": "req",
"id": "1",
"method": "connect",
"params": {
"token": "YOUR_GATEWAY_TOKEN",
"user_id": "user_123"
}
}
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `token` | string | No | Gateway API token (empty = viewer role) |
| `user_id` | string | Yes | Client/user identifier (opaque, max 255 chars) |
**Response:**
```json
{
"type": "res",
"id": "1",
"ok": true,
"payload": {
"protocol": 3,
"role": "admin",
"user_id": "user_123"
}
}
```
### Roles
- **viewer** (default): Read-only access (no token or wrong token)
- **operator**: Read + write + chat
- **admin**: Full control (with correct gateway token)
## Sending Messages
After authentication, send `chat.send` request:
```json
{
"type": "req",
"id": "2",
"method": "chat.send",
"params": {
"agentId": "main",
"message": "What is 2+2?",
"channel": "websocket"
}
}
```
**Parameters:**
| Field | Type | Description |
|-------|------|-------------|
| `agentId` | string | Agent to query |
| `message` | string | User message |
| `channel` | string | Usually `"websocket"` |
| `sessionId` | string | Optional: resume existing session |
**Response:**
```json
{
"type": "res",
"id": "2",
"ok": true,
"payload": {
"content": "2+2 equals 4.",
"usage": {
"input_tokens": 42,
"output_tokens": 8
}
}
}
```
## Streaming Events
During agent processing, server pushes events:
```json
{
"type": "event",
"event": "chat",
"payload": {
"chunk": "2+2 equals",
"delta": " equals"
},
"seq": 1
}
```
**Event Types:**
| Event | Payload | Description |
|-------|---------|-------------|
| `chat` | `{chunk, delta}` | Streaming text chunks |
| `agent` | `{run_id, status}` | Agent lifecycle (started, completed, failed) |
| `tool.call` | `{tool, input}` | Tool invocation |
| `tool.result` | `{tool, output}` | Tool result |
## Minimal JavaScript Client
```javascript
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => {
// Authenticate
ws.send(JSON.stringify({
type: 'req',
id: '1',
method: 'connect',
params: {
user_id: 'web_client_1'
}
}));
};
ws.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === 'res' && frame.id === '1') {
// Connected! Now send a message
ws.send(JSON.stringify({
type: 'req',
id: '2',
method: 'chat.send',
params: {
agentId: 'main',
message: 'Hello!',
channel: 'websocket'
}
}));
}
if (frame.type === 'res' && frame.id === '2') {
console.log('Response:', frame.payload.content);
}
if (frame.type === 'event' && frame.event === 'chat') {
console.log('Chunk:', frame.payload.chunk);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected');
};
```
## Session Management
Reuse a session ID to continue conversations:
```json
{
"type": "req",
"id": "3",
"method": "chat.send",
"params": {
"agentId": "main",
"message": "Add 5 to the result.",
"sessionId": "session_xyz",
"channel": "websocket"
}
}
```
Session ID is returned in each response. Store and pass it to maintain conversation history.
## Keepalive
Server sends ping frames every 30 seconds. Client should respond with pong. Most WebSocket libraries do this automatically.
## Frame Limits
| Limit | Value |
|-------|-------|
| Read message size | 512 KB |
| Read deadline | 60 seconds |
| Write deadline | 10 seconds |
| Send buffer | 256 messages |
Messages exceeding limits are dropped with logging.
## Error Handling
Failed requests include error details:
```json
{
"type": "res",
"id": "2",
"ok": false,
"error": {
"code": "INVALID_REQUEST",
"message": "unknown method",
"retryable": false
}
}
```
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Connection refused" | Check gateway is running on correct host/port. |
| "Unauthorized" | Verify token is correct. Check user_id is provided. |
| "Message too large" | Reduce message size (512 KB limit). |
| No streaming events | Ensure provider supports streaming. Check model config. |
| Connection drops | Server may have hit message buffer limit. Reconnect and resume session. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [WebSocket Protocol](/websocket-protocol) — Full protocol documentation
- [Browser Pairing](/channel-browser-pairing) — Pairing flow for custom clients
---
# Browser Pairing
Secure authentication flow for custom WebSocket clients using 8-character pairing codes. Ideal for private web apps and desktop clients that need to verify device identity.
> **Browser pairing vs. cookie sync.** Browser **pairing** authenticates a *client* (a browser or desktop app) so it can talk to the gateway. **Selected cookie sync** copies *site login cookies* into an agent's server-side browser so the agent can act as a signed-in session. Use pairing to let a device connect; use cookie sync to give an agent access to a site you are logged into. See [Browser Automation → Selected Cookie Sync](/browser-automation#selected-cookie-sync).
## Pairing Flow
```mermaid
sequenceDiagram
participant C as Client (Browser)
participant G as Gateway
participant O as Owner (CLI/Dashboard)
C->>G: Request pairing code
G->>C: Generate code: ABCD1234
(valid 60 min)
G->>O: Notify: New pairing request
from client_id
Note over C: User shows code to owner
O->>G: Approve code: device.pair.approve
code=ABCD1234
G->>G: Add to paired_devices
Mark request resolved
C->>G: Connect with code: ABCD1234
G->>G: Verify against paired_devices
G->>C: OK, authenticated!
Issue session token
C->>G: WebSocket: chat.send
with pairing token
G->>C: Response + events
```
## Code Format
**Generation:**
- Length: 8 characters
- Alphabet: `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (excludes ambiguous: 0, O, 1, I, L)
- TTL: 60 minutes
- Max pending per account: 3
**Example codes:**
- `ABCD1234`
- `XY8PQRST`
- `2M5H9JKL`
## Implementation
### Step 1: Request Code (Client)
```bash
curl -X POST http://localhost:8080/v1/device/pair/request \
-H "Content-Type: application/json" \
-d '{
"client_id": "browser_myclient_1",
"device_name": "My Web App"
}'
```
**Response:**
```json
{
"code": "ABCD1234",
"expires_at": 1709865000,
"url": "http://localhost:8080/pair?code=ABCD1234"
}
```
Display code to user:
```
Please share this code with your gateway owner:
ABCD1234
It expires in 60 minutes.
```
### Step 2: Approve Code (Owner)
Owner runs CLI command or uses dashboard to approve:
```bash
goclaw device.pair.approve --code ABCD1234
```
Or via WebSocket (admin only):
```json
{
"type": "req",
"id": "100",
"method": "device.pair.approve",
"params": {
"code": "ABCD1234"
}
}
```
**Response:**
```json
{
"type": "res",
"id": "100",
"ok": true,
"payload": {
"client_id": "browser_myclient_1",
"device_name": "My Web App",
"paired_at": 1709864400
}
}
```
### Step 3: Connect (Client)
Client uses the code to authenticate:
```json
{
"type": "req",
"id": "1",
"method": "connect",
"params": {
"pairing_code": "ABCD1234",
"user_id": "web_user_1"
}
}
```
**Response:**
```json
{
"type": "res",
"id": "1",
"ok": true,
"payload": {
"protocol": 3,
"role": "operator",
"user_id": "web_user_1",
"session_token": "session_xyz..."
}
}
```
Client stores `session_token` for future connections.
### Step 4: Use Session (Client)
On reconnect, use stored token:
```json
{
"type": "req",
"id": "1",
"method": "connect",
"params": {
"session_token": "session_xyz...",
"user_id": "web_user_1"
}
}
```
## Security Properties
- **One-time use**: Each pairing code is used once and invalidated
- **Expiring**: Codes expire after 60 minutes (TTL enforced server-side)
- **Limited pending**: Max 3 pending requests per account (prevents spam)
- **Owner approval**: Only gateway owner can approve codes (admin role required)
- **Session tokens**: Issued after approval; tied to device and user
- **Debouncing**: Pairing approval notifications debounced per sender (60 seconds)
- **Fail-closed auth**: Authentication failures default to deny — no partial or ambiguous approval states
- **Rate limiting**: Pairing code requests are rate-limited per sender to prevent brute-force enumeration
- **Transient DB error handling**: `IsPaired` checks handle transient database errors gracefully — a DB error returns denied rather than accidentally allowing access
## Channel-Specific Pairing Behaviour
How channels trigger the pairing flow varies by channel type.
**Discord groups:** When `group_policy: "pairing"` is active and `require_mention: true` (the Discord default), a pairing invite is only sent to an unpaired user if they explicitly @mentioned the bot or replied to one of the bot's messages. Messages sent to the group that do not address the bot are silently buffered and do not produce a pairing reply. This prevents pairing spam in public servers where many users can see the bot's messages.
For DMs, the pairing invite fires unconditionally whenever an unpaired user sends a message (same as other channels).
## JavaScript Example
```javascript
class PairingClient {
constructor(gatewayUrl) {
this.url = gatewayUrl;
this.ws = null;
this.sessionToken = localStorage.getItem('goclaw_token');
}
async requestPairingCode() {
const res = await fetch(`${this.url}/v1/device/pair/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: 'browser_' + Date.now(),
device_name: navigator.userAgent
})
});
const data = await res.json();
return data.code;
}
connect() {
this.ws = new WebSocket(this.url.replace('http', 'ws') + '/ws');
this.ws.onopen = () => {
if (this.sessionToken) {
// Resume with token
this.send('connect', {
session_token: this.sessionToken,
user_id: 'user_' + Date.now()
});
} else {
console.log('No session token. Request pairing code first.');
}
};
this.ws.onmessage = (e) => this.handleMessage(JSON.parse(e.data));
}
send(method, params) {
this.ws.send(JSON.stringify({
type: 'req',
id: Date.now().toString(),
method,
params
}));
}
handleMessage(frame) {
if (frame.type === 'res' && frame.payload?.session_token) {
localStorage.setItem('goclaw_token', frame.payload.session_token);
}
// Handle response...
}
}
```
## Troubleshooting
| Issue | Solution |
|-------|----------|
| "Code expired" | Code is valid only 60 minutes. Request new code. |
| "Code not found" | Code never existed or already used. Request new code. |
| "Max pending exceeded" | Too many pending requests. Wait or have owner revoke old codes. |
| "Unauthorized" | Owner has not approved the code yet. Check with owner. |
| Session token invalid | Token may have expired or been revoked. Request new pairing code. |
## What's Next
- [Overview](/channels-overview) — Channel concepts and policies
- [WebSocket](/channel-websocket) — Direct RPC communication
- [Telegram](/channel-telegram) — Telegram setup
- [WebSocket Protocol](/websocket-protocol) — Full protocol reference
---
# What Are Agent Teams?
Agent teams enable multiple agents to collaborate on shared tasks. A **lead** agent orchestrates work, while **members** execute tasks independently and report results back.
## The Team Model
Teams consist of:
- **Lead Agent**: Orchestrates work, creates and assigns tasks via `team_tasks`, delegates to members, synthesizes results
- **Member Agents**: Receive dispatched tasks, execute independently, complete with results, can send progress updates via mailbox
- **Shared Task Board**: Track work, dependencies, priority, status
- **Team Mailbox**: Direct messages between all team members via `team_message`
```mermaid
flowchart TD
subgraph Team["Agent Team"]
LEAD["Lead Agent
Orchestrates work, creates tasks,
delegates to members, synthesizes results"]
M1["Member A
Claims and executes tasks"]
M2["Member B
Claims and executes tasks"]
M3["Member C
Claims and executes tasks"]
end
subgraph Shared["Shared Resources"]
TB["Task Board
Create, claim, complete tasks"]
MB["Mailbox
Direct messages, broadcasts"]
end
USER["User"] -->|message| LEAD
LEAD -->|create task + delegate| M1 & M2 & M3
M1 & M2 & M3 -->|results auto-announced| LEAD
LEAD -->|synthesized response| USER
LEAD & M1 & M2 & M3 <--> TB
LEAD & M1 & M2 & M3 <--> MB
```
## Key Design Principles
**Lead-centric TEAM.md**: Only the lead receives `TEAM.md` with full orchestration instructions — mandatory workflow, delegation patterns, follow-up reminders. Members discover context on demand through tools; no wasted tokens on idle agents.
**Mandatory task tracking**: Every delegation from a lead must be linked to a task on the board. The system enforces this — delegations without a `team_task_id` are rejected, with a list of pending tasks provided to help the lead self-correct.
**Auto-completion**: When a delegation finishes, the linked task is automatically marked as complete. Files created during execution are auto-linked to the task. No manual bookkeeping.
**Blocker escalation**: Members can flag themselves as blocked by posting a blocker comment on a task. This auto-fails the task and delivers an escalation message to the lead with the blocked member name, task subject, blocker reason, and retry instructions.
**Parallel batching**: When multiple members work simultaneously, results are collected and delivered to the lead in a single combined announcement.
**Member scope**: Members do not have spawn or delegate access. They work within the team structure — executing tasks, reporting progress, and communicating via mailbox.
## Team Workspace
Each team has a shared workspace for files produced during task execution. Workspace scoping is configurable:
| Mode | Directory | Use Case |
|------|-----------|----------|
| **Isolated** (default) | `{dataDir}/teams/{teamID}/{chatID}/` | Per-conversation isolation |
| **Shared** | `{dataDir}/teams/{teamID}/` | All members access same folder |
Configure via `workspace_scope: "shared"` in team settings. Files written during task execution are automatically stored in the workspace and linked to the active task.
## v3 Orchestration Changes
In v3, teams use a **task-board-driven dispatch model** instead of the old `spawn(agent=...)` flow.
### Post-Turn Dispatch (BatchQueue)
Tasks created during a lead's turn are queued (`PendingTeamDispatchFromCtx`) and dispatched **after the turn ends** — not inline. This ensures `blocked_by` dependencies are fully wired before any member receives work.
```
Lead turn ends
→ BatchQueue flushes pending dispatches
→ Each assignee receives inbound message via bus
→ Member agents execute in isolated sessions
```
### Domain Event Bus
All task state changes emit typed events (`team_task.created`, `team_task.assigned`, `team_task.completed`, etc.) on the domain event bus. The dashboard updates in real-time via WebSocket without polling.
### Circuit Breaker
Tasks auto-fail after **3 dispatch attempts** (`maxTaskDispatches`). This prevents infinite loops when a member agent repeatedly fails or rejects a task. The dispatch count is tracked in `metadata.dispatch_count`.
### WaitAll Pattern
The lead can create multiple tasks in parallel and they dispatch concurrently. When all member tasks complete, `DispatchUnblockedTasks` auto-dispatches any waiting dependent tasks (ordered by priority). The lead synthesizes results only after all branches resolve.
> **Spawn tool change**: `spawn(agent="member")` is no longer valid in v3. Leads must use `team_tasks(action="create", assignee="member")` instead. The system will reject direct spawn-to-agent calls with an instructive error.
## Real-World Example
**Scenario**: User asks the lead to analyze a research paper and write a summary.
1. Lead receives request
2. Lead calls `team_tasks(action="create", subject="Extract key points from paper", assignee="researcher")` — system dispatches to researcher with a linked `team_task_id`
3. Researcher receives task, works independently, calls `team_tasks(action="complete", result="")` — linked task auto-completed, lead is notified
4. Lead calls `team_tasks(action="create", subject="Write summary", assignee="writer", description="Use researcher findings: ", blocked_by=[""])`
5. Writer's task unblocks automatically when researcher finishes, writer completes with result
6. Lead synthesizes and sends final response to user
## Teams vs Other Delegation Models
| Aspect | Agent Team | Simple Delegation | Agent Link |
|--------|-----------|-------------------|-----------|
| **Coordination** | Lead orchestrates with task board | Parent waits for result | Direct peer-to-peer |
| **Task Tracking** | Shared task board, dependencies, priorities | No tracking | No tracking |
| **Messaging** | All members use mailbox | Parent-only | Parent-only |
| **Scalability** | Designed for 3-10 members | Simple parent-child | One-to-one links |
| **TEAM.md Context** | Lead gets full instructions; members get execution guidance | Not applicable | Not applicable |
| **Use Case** | Parallel research, content review, analysis | Quick delegate & wait | Conversation handoff |
**Use Teams When**:
- 3+ agents need to work together
- Tasks have dependencies or priorities
- Members need to communicate
- Results need parallel batching
**Use Simple Delegation When**:
- One parent delegates to one child
- Need quick synchronous result
- No inter-team communication required
**Use Agent Links When**:
- Conversation needs to transfer between agents
- No task board or orchestration needed
---
# Creating & Managing Teams
Create teams via API, Dashboard, or CLI. The system automatically establishes delegation links between the lead and all members, injects `TEAM.md` into the lead's system prompt, and wires up task board access for all members.
## Quick Start
**Create a team** with lead agent and members:
```bash
# CLI
./goclaw team create \
--name "Research Team" \
--lead researcher_agent \
--members analyst_agent,writer_agent \
--description "Parallel research and writing"
```
**Via WebSocket RPC** (`teams.create`):
```json
{
"name": "Research Team",
"lead": "researcher_agent",
"members": ["analyst_agent", "writer_agent"],
"description": "Parallel research and writing"
}
```
**Dashboard**: Teams → Create Team → Select Lead → Add Members → Save
The Teams list page supports a **card/list toggle** for switching between visual card layout and a compact list view.
## What Happens on Creation
When you create a team, the system:
1. **Validates** lead and member agents exist
2. **Creates team record** with `status=active`
3. **Adds lead as a member** with `role=lead`
4. **Adds each member** with `role=member`
5. **Auto-creates delegation links** from lead → each member:
- Direction: `outbound` (lead can delegate to members)
- Max concurrent delegations per link: `3`
- Marked with `team_id` (system knows these are team-managed)
6. **Injects TEAM.md** into the lead's system prompt with full orchestration instructions
7. **Enables task board** for all team members
## Team Lifecycle
```mermaid
flowchart TD
CREATE["Admin creates team
(name, lead, members)"] --> LINK["Auto-create delegation links
Lead → each member"]
LINK --> INJECT["TEAM.md auto-injected
into lead's system prompt"]
INJECT --> READY["Team ready for use"]
READY --> MANAGE["Admin manages team"]
MANAGE --> ADD["Add member
→ auto-link lead→member"]
MANAGE --> REMOVE["Remove member
→ team links auto-deleted"]
MANAGE --> DELETE["Delete team
→ record hard-deleted from DB"]
```
## Managing Team Membership
**Add a member** (role is `member` by default):
```bash
./goclaw team add-member \
--team-id 550e8400-e29b-41d4-a716-446655440000 \
--agent analyst_agent \
--role member
# When added, a delegation link is automatically created
# from lead → new member
```
**Remove a member**:
```bash
./goclaw team remove-member \
--team-id 550e8400-e29b-41d4-a716-446655440000 \
--agent-id
# Team-specific delegation links are automatically cleaned up on removal
```
**List team members**:
```bash
./goclaw team list-members --team-id 550e8400-e29b-41d4-a716-446655440000
# Output:
# Agent Key Role Display Name
# researcher_agent lead Research Expert
# analyst_agent member Data Analyst
# writer_agent member Content Writer
```
Member info returned by the API is enriched with full **agent metadata** (display name, emoji, description, model) so the dashboard can render rich member cards.
## Lead vs Member Roles
| Capability | Lead | Member |
|-----------|------|--------|
| Receives full TEAM.md (orchestration instructions) | Yes | No (discovers context via tools) |
| Creates tasks on board | Yes | No |
| Delegates tasks to members | Yes | No |
| Executes delegated tasks | No | Yes |
| Reports progress via task board | No | Yes |
| Sends/receives mailbox messages | Yes | Yes |
| Spawn / delegate access | Yes | No |
| Self-assign tasks | No | N/A |
> **Note**: The lead agent cannot self-assign tasks. Attempting to do so is rejected to prevent a dual-session loop where the lead acts as both coordinator and executor.
Members work within the team structure. They do not have spawn or delegate capabilities — their role is to execute assigned tasks and report results.
## Team Settings & Access Control
Teams support fine-grained access control and behavior configuration via settings JSON:
```json
{
"allow_user_ids": ["user_123", "user_456"],
"deny_user_ids": [],
"allow_channels": ["telegram", "slack"],
"deny_channels": [],
"progress_notifications": true,
"followup_interval_minutes": 30,
"followup_max_reminders": 3,
"escalation_mode": "notify_lead",
"escalation_actions": [],
"workspace_scope": "isolated",
"workspace_quota_mb": 500,
"blocker_escalation": {
"enabled": true
}
}
```
**Access control fields**:
- `allow_user_ids`: Only these users can trigger team work (empty = open access)
- `deny_user_ids`: Block these users (deny takes priority over allow)
- `allow_channels`: Only messages from these channels trigger team work (empty = open)
- `deny_channels`: Block messages from these channels
System channels (`teammate`, `system`) always pass access checks regardless of settings.
**Follow-up & escalation fields**:
- `followup_interval_minutes`: Minutes between auto follow-up reminders on in-progress tasks
- `followup_max_reminders`: Maximum number of follow-up reminders per task
- `escalation_mode`: How to handle stale tasks — `"notify_lead"` (send notification) or `"fail_task"` (auto-fail the task)
- `escalation_actions`: Additional actions to take on escalation
**Blocker escalation**:
- `blocker_escalation.enabled`: Whether blocker comments auto-fail tasks and escalate to lead (default: `true`)
When `blocker_escalation` is enabled (default), if a member posts a blocker comment on a task, the task is auto-failed and the lead receives an escalation message with the blocker reason and retry instructions. Set `enabled: false` to save blocker comments without triggering auto-fail.
**Workspace fields**:
- `workspace_scope`: `"isolated"` (default, per-conversation folders) or `"shared"` (all members share one folder)
- `workspace_quota_mb`: Disk quota for team workspace in megabytes
**Other fields**:
- `progress_notifications`: Send periodic updates during async delegations
**Set team settings**:
```bash
./goclaw team update \
--team-id 550e8400-e29b-41d4-a716-446655440000 \
--settings '{
"allow_user_ids": ["user_123"],
"allow_channels": ["telegram"],
"blocker_escalation": {"enabled": true},
"escalation_mode": "notify_lead"
}'
```
## Team Status
Teams have a `status` field:
- `active`: Team is operational
- `archived`: Team exists but disabled
To fully remove a team, use the delete operation — it hard-deletes the record from the database. There is no `deleted` status.
**Change team status**:
```bash
./goclaw team update \
--team-id 550e8400-e29b-41d4-a716-446655440000 \
--status archived
```
## Team Members in System Prompt
When a team is active, GoClaw injects a `## Team Members` section into the lead agent's system prompt listing all teammates. Each entry is enriched with agent metadata including emoji icon (from `other_config`):
```
## Team Members
- agent_key: analyst_agent | display_name: 🔍 Data Analyst | role: member | expertise: Data analysis and visualization...
- agent_key: writer_agent | display_name: ✍️ Content Writer | role: member | expertise: Technical writing...
```
This lets the lead assign tasks to the correct agent by key without guessing. The section updates automatically when members are added or removed.
## Lead Workspace Resolution
When a team task is dispatched, the lead agent resolves the per-team workspace directory for both lead and member agents. This resolution is transparent — agents use normal file paths and the **WorkspaceInterceptor** rewrites requests to the correct team workspace context automatically.
For isolated scope (`workspace_scope: "isolated"`), each conversation gets its own folder. For shared scope, all members read and write to the same team directory.
## Media Auto-Copy
When a task is created from a conversation that includes media files (images, documents), GoClaw automatically copies those files to the team workspace at `{team_workspace}/attachments/`. Hard links are used when possible for efficiency, with a copy fallback. Files are validated and saved with restrictive permissions (0640).
## TEAM.md Injection
`TEAM.md` is a virtual file generated dynamically at agent resolution time — not stored on disk. It is injected into the system prompt wrapped in `` tags.
**Lead's TEAM.md** includes:
- Team name and description
- Teammate list with roles and expertise
- **Mandatory workflow**: create task first, then delegate with task ID — delegations without a valid `team_task_id` are rejected
- **Orchestration patterns**: sequential, iterative, parallel, mixed
- Communication guidelines
**Members' TEAM.md** includes:
- Team name and teammate list
- Instructions to focus on delegated work
- How to report progress via `team_tasks(action="progress", percent=50, text="...")`
- Task board actions available: `claim`, `complete`, `list`, `get`, `search`, `progress`, `comment`, `attach`, `retry` (no `create`, `cancel`, `approve`, `reject`)
The context refreshes automatically when team configuration changes (members added/removed, settings updated).
## Next Steps
- [Task Board](./task-board.md) - Create and manage tasks
- [Team Messaging](./team-messaging.md) - Communicate between members
- [Delegation & Handoff](./delegation-and-handoff.md) - Orchestrate work
---
# Task Board
The task board is a shared work tracker accessible to all team members. Tasks can be created with priorities, dependencies, and blocking constraints. Members claim pending tasks, work independently, and mark them complete with results.
The dashboard renders the board as a **Kanban layout** with columns per status. The board toolbar includes a workspace button and agent emoji display for quick identification of who owns each task.
## Task Lifecycle
```mermaid
flowchart TD
PENDING["Pending
(just created, ready to claim)"] -->|claim| IN_PROGRESS["In Progress
(agent working)"]
PENDING -->|blocked_by set| BLOCKED["Blocked
(waiting for dependencies)"]
BLOCKED -->|all blockers done| PENDING
IN_PROGRESS -->|complete| COMPLETED["Completed
(with result)"]
IN_PROGRESS -->|review| IN_REVIEW["In Review
(awaiting approval)"]
IN_REVIEW -->|approve| COMPLETED
IN_REVIEW -->|reject| CANCELLED["Cancelled"]
PENDING -->|cancel| CANCELLED
IN_PROGRESS -->|cancel| CANCELLED
IN_PROGRESS -->|agent error| FAILED["Failed
(error)"]
PENDING -->|system failure| STALE["Stale
(timed out)"]
IN_PROGRESS -->|system failure| STALE
FAILED -->|retry| PENDING
STALE -->|retry| PENDING
```
## Core Tool: `team_tasks`
All team members access the task board via the `team_tasks` tool. Available actions:
| Action | Required Params | Description |
|--------|-----------------|-------------|
| `list` | `action` | Show tasks (default filter: all statuses; page size: 30) |
| `get` | `action`, `task_id` | Get full task detail with comments, events, attachments (result: 8,000 char limit) |
| `create` | `action`, `subject`, `assignee` | Create new task (lead only); `assignee` is **mandatory**; optional: `description`, `priority`, `blocked_by`, `require_approval` |
| `claim` | `action`, `task_id` | Atomically claim a pending task |
| `complete` | `action`, `task_id`, `result` | Mark task done with result summary |
| `cancel` | `action`, `task_id` | Cancel task (lead only); optional: `text` (reason) |
| `assign` | `action`, `task_id`, `assignee` | Admin-assign a pending task to an agent |
| `search` | `action`, `query` | Full-text search over subject + description (check before creating to avoid duplicates) |
| `review` | `action`, `task_id` | Submit in-progress task for review; transitions to `in_review` (owner only) |
| `approve` | `action`, `task_id` | Approve a task in review → `completed` (lead/admin only) |
| `reject` | `action`, `task_id` | Reject a task in review → `cancelled` with reason injected to lead (lead/admin only); optional: `text` |
| `comment` | `action`, `task_id`, `text` | Add a comment; use `type="blocker"` to flag a blocker (triggers auto-fail + lead escalation) |
| `progress` | `action`, `task_id`, `percent` | Update progress 0-100 (owner only); optional: `text` (step description) |
| `update` | `action`, `task_id` | Update task subject or description (lead only) |
| `attach` | `action`, `task_id`, `file_id` | Attach a workspace file to a task |
| `ask_user` | `action`, `task_id`, `text` | Set a periodic follow-up reminder sent to user (owner only) |
| `clear_followup` | `action`, `task_id` | Clear ask_user reminders (owner or lead) |
| `retry` | `action`, `task_id` | Re-dispatch a `stale` or `failed` task back to `pending` (admin/lead) |
| `delete` | `action`, `task_id` | Hard-delete a task in terminal status (completed/cancelled/failed) from the board |
## Create a Task
**Lead creates a task** for members to work on:
> **Note**: The `assignee` field is **mandatory** at task creation. Omitting it returns an error: `"assignee is required — specify which team member should handle this task"`.
> **Note**: Agents must call `search` before `create` to avoid duplicate tasks. Creating without checking first returns an error prompting the search.
> **Note**: Team V2 leads cannot manually create tasks before a spawn has been issued in the current turn — this prevents premature task creation that breaks the structured orchestration flow.
```json
{
"action": "create",
"subject": "Extract key points from research paper",
"description": "Read the PDF and summarize main findings in bullet points",
"priority": 10,
"assignee": "researcher",
"blocked_by": []
}
```
**Response**:
```
Task created: Extract key points from research paper (id=, identifier=TSK-1, status=pending)
```
The `identifier` field (e.g. `TSK-1`) is a short human-readable reference generated from the team name prefix and task number.
**With dependencies** (blocked_by):
```json
{
"action": "create",
"subject": "Write summary",
"priority": 5,
"assignee": "writer_agent",
"blocked_by": [""]
}
```
This task stays `blocked` until the first task is `completed`. When you complete the blocker, this task automatically transitions to `pending` and becomes claimable.
**With approval required** (require_approval):
```json
{
"action": "create",
"subject": "Deploy to production",
"assignee": "devops_agent",
"require_approval": true
}
```
Task starts in `pending` status with `require_approval` flag set. After the member calls `review`, it enters `in_review` and must be approved before completing.
## Claim & Complete a Task
**Member claims a pending task**:
```json
{
"action": "claim",
"task_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Atomic claiming**: Database ensures only one agent succeeds. If two agents try to claim the same task, one gets `claimed successfully`; the other gets `failed to claim task` (someone else beat you).
**Member completes the task**:
```json
{
"action": "complete",
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"result": "Extracted 12 key findings:\n1. Main hypothesis confirmed\n2. Data suggests..."
}
```
**Auto-claim**: You can skip the claim step. Calling `complete` on a pending task auto-claims it (one API call instead of two).
> **Note**: Delegate agents cannot call `complete` directly — their results are auto-completed when delegation finishes.
## Task Delete
Terminal-status tasks (completed, cancelled, failed) can be hard-deleted from the board:
```json
{
"action": "delete",
"task_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
Delete is only permitted when the task is in a terminal state. Attempting to delete an active task returns an error. The dashboard also exposes a delete button in the task detail view. A `team.task.deleted` WebSocket event is emitted on success.
## Task Dependencies & Auto-Unblock
When you create a task with `blocked_by: [task_A, task_B]`:
- Task status is set to `blocked`
- Task remains unclaimable
- When **all** blockers are `completed`, task automatically transitions to `pending`
- Members are notified the task is ready
```mermaid
flowchart LR
A["Task A
Research"] -->|complete| A_DONE["Task A: completed"]
B["Task B
Analysis"] -->|complete| B_DONE["Task B: completed"]
C["Task C: blocked
blockers=[A,B]"]
A_DONE --> UNBLOCK["Check blockers"]
B_DONE --> UNBLOCK
UNBLOCK -->|all done| C_READY["Task C: pending
(ready to claim)"]
```
**Blocked_by validation**: The system validates that `blocked_by` references do not create circular dependencies or reference tasks in terminal states that would make the block unresolvable.
## Blocker Escalation
When a member is stuck, they post a blocker comment:
```json
{
"action": "comment",
"task_id": "550e8400-...",
"text": "Cannot find API documentation",
"type": "blocker"
}
```
What happens automatically:
1. Comment saved with `comment_type='blocker'`
2. Task **auto-fails** (`in_progress` → `failed`)
3. Member's session is cancelled; UI dashboard updates in real-time
4. **Lead receives an escalation message** from `system:escalation` with the blocked member name, task number, blocker reason, and a `retry` instruction
The lead can then fix the issue and re-dispatch:
```json
{
"action": "retry",
"task_id": "550e8400-..."
}
```
Blocker escalation is enabled by default. Disable per-team via settings: `{"blocker_escalation": {"enabled": false}}`.
## Review Workflow
For tasks requiring human approval, set `require_approval: true` at creation:
1. **Member submits**: `action="review"` → task moves to `in_review`
2. **Human approves** (dashboard): `action="approve"` → task moves to `completed`
3. **Human rejects** (dashboard): `action="reject"` → task moves to `cancelled`; lead receives notification with reason
Without `require_approval`, tasks move directly to `completed` after `complete` (no in_review stage).
**Filtering**: The dashboard supports filtering by all task statuses including `in_review`, `cancelled`, and `failed`. The default status filter shows **all** tasks (page size: 30).
## Task Snapshots
Completed tasks automatically store snapshots in their `metadata` field for board visualization:
```json
{
"snapshot": {
"completed_at": "2026-03-16T12:34:56Z",
"result_preview": "First 100 chars of result...",
"final_status": "completed",
"ai_summary": "Brief AI-generated summary of what was accomplished"
}
}
```
The Kanban board displays these snapshots as cards, allowing users to review completed work at a glance without opening the full task detail.
## List & Search
**List tasks** (default shows all statuses, 30 per page):
```json
{
"action": "list"
}
```
**Filter by status**:
```json
{
"action": "list",
"status": "in_review"
}
```
Valid `status` filter values:
| Value | Returns |
|-------|---------|
| `""` or `"all"` (default) | All tasks regardless of status |
| `"active"` | Active tasks: pending, in_progress, blocked |
| `"completed"` | Completed and cancelled tasks |
| `"in_review"` | Tasks awaiting approval |
**Search** for specific tasks:
```json
{
"action": "search",
"query": "research paper"
}
```
Results show snippet (500 char max) of full result. Use `action=get` for complete result.
## Priority & Ordering
Tasks are ordered by priority (highest first), then by creation time. Higher priority = gets sorted to top of list:
```json
{
"action": "create",
"subject": "Urgent fix needed",
"assignee": "fixer_agent",
"priority": 100
}
```
## User Scoping
Access differs by channel:
- **Delegate/system channels**: See all team tasks
- **End users**: See only tasks they triggered (filtered by user ID)
Results are truncated:
- `action=list`: Results not shown (use `get` for full)
- `action=get`: 8,000 characters max
- `action=search`: 500 character snippets
## Get Full Task Details
```json
{
"action": "get",
"task_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
**Response** includes:
- Full task metadata (including `identifier`, `task_number`, `progress_percent`, snapshot)
- Complete result text (truncated at 8,000 chars if needed)
- Owner agent key and display name with emoji
- Timestamps
- Comments, audit events, and attachments (if any)
## Cancel a Task
**Lead cancels a task**:
```json
{
"action": "cancel",
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"text": "User request changed, no longer needed"
}
```
Note: the cancel reason is passed via the `text` parameter (not `reason`).
**What happens**:
- Task status → `cancelled`
- If delegation is running for this task, it's stopped immediately
- Any dependent tasks (with `blocked_by` pointing here) become unblocked
## Improved Task Dispatch Concurrency
Task dispatch uses a post-turn queue to avoid race conditions: tasks created by the lead during a turn are queued and dispatched together after the turn ends. This means:
- Dependencies set via `blocked_by` are fully resolved before any dispatch fires
- Only one task per assignee is dispatched per round (priority-ordered) to prevent cancellation conflicts
- Completed blocker results are automatically appended to the dispatch content for unblocked tasks
## Best Practices
1. **Create tasks first**: Always create a task before delegating work (lead only)
2. **Always set assignee**: The `assignee` field is mandatory — specify the team member at creation
3. **Search before creating**: Use `action=search` to check for similar tasks before creating to avoid duplicates
4. **Use priority**: Set priority based on urgency (100 = urgent, 10 = high, 0 = normal)
5. **Add dependencies**: Link related tasks with `blocked_by` to enforce order
6. **Include context**: Write clear descriptions so members know what to do
7. **Use blocker comments**: If stuck, post a `type="blocker"` comment — the lead is automatically notified
8. **Delete completed clutter**: Use `action=delete` on terminal tasks to keep the board clean
---
# Team Messaging
Team members communicate via a built-in mailbox system. Members can send direct messages and read unread messages. The lead agent does not have access to the `team_message` tool — it is removed from the lead's tool list by policy. Messages flow through the message bus with real-time delivery.
## Mailbox Tool: `team_message`
All team members access the mailbox via the `team_message` tool. Actions:
| Action | Params | Description |
|--------|--------|-------------|
| `send` | `to`, `text`, `media` (optional) | Send direct message to specific teammate |
| `broadcast` | `text` | Send message to all teammates (except self); system/teammate channel only |
| `read` | none | Get unread messages; auto-marks as read |
## Send a Direct Message
**Member sends message to another member**:
```json
{
"action": "send",
"to": "analyst_agent",
"text": "Please review my findings from task 123. I need your input on the methodology."
}
```
**What happens**:
1. Message is persisted to database
2. A "message" task is auto-created on the team task board (visible in Tasks tab)
3. Recipient is notified in real-time via message bus (channel: `system`, sender: `teammate:{sender_key}`)
4. Event broadcast to UI for real-time updates
**Response**:
```
Message sent to analyst_agent.
```
**Cross-team protection**: You can only message team members. Attempting to message someone outside your team fails with `"agent is not a member of your team"`.
## Broadcast to All Members
Broadcast delivers a message to all team members simultaneously. This action is restricted to system/teammate channels (internal operations) — regular member agents cannot call `broadcast` directly.
```json
{
"action": "broadcast",
"text": "Important update: We've decided to focus on the top 5 findings. Please adjust your work accordingly."
}
```
**What happens**:
1. Message persisted as broadcast (to_agent_id = NULL)
2. Message type: `broadcast`
3. Each team member (except sender) receives the message
4. Event broadcast to UI for all to see
**Response**:
```
Broadcast sent to all teammates.
```
## Read Unread Messages
**Check mailbox**:
```json
{
"action": "read"
}
```
**Response**:
```json
{
"messages": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"team_id": "...",
"from_agent_id": "...",
"from_agent_key": "researcher_agent",
"to_agent_key": "analyst_agent",
"message_type": "chat",
"content": "Please review my findings...",
"read": false,
"created_at": "2025-03-08T10:30:00Z"
}
],
"count": 1
}
```
**Auto-marking**: Reading messages automatically marks them as read. Next `read` call will only show new unread messages.
**Pagination**: Returns up to 50 unread messages per call. If more exist, the response includes `"has_more": true` and a note to call `read` again after processing.
## Message Routing
Messages flow through the system with special routing:
```mermaid
flowchart TD
SEND["team_message send/broadcast"] --> PERSIST["Persist to DB"]
PERSIST --> BUS["Message Bus
Channel: 'system'
SenderID: 'teammate:{sender_key}'"]
BUS --> TARGET["Route to target agent session"]
TARGET --> DISPLAY["Display in conversation"]
```
**Message format on delivery**:
```
[Team message from researcher_agent]: Please review my findings...
```
The `teammate:` prefix in the sender ID tells the consumer to route the message to the correct team member's session, not the general user session.
## Domain Event Bus
In addition to mailbox messages, GoClaw uses a typed **Domain Event Bus** (`eventbus.DomainEventBus`) for internal event propagation across the v3 pipeline. This is separate from the channel message bus used for routing.
The domain event bus is defined in `internal/eventbus/domain_event_bus.go`:
```go
type DomainEventBus interface {
Publish(event DomainEvent) // non-blocking enqueue
Subscribe(eventType EventType, handler DomainEventHandler) func() // returns unsubscribe fn
Start(ctx context.Context)
Drain(timeout time.Duration) error
}
```
**Key properties**:
- Async worker pool (default 2 workers, queue depth 1000)
- Per-`SourceID` dedup window (default 5 minutes) — prevents duplicate processing
- Configurable retry (default 3 attempts with exponential backoff)
- Graceful drain on shutdown
**Event types catalog** (defined in `eventbus/event_types.go`):
| Event Type | Trigger |
|-----------|---------|
| `session.completed` | Session ends or context is compacted |
| `episodic.created` | Episodic memory summary stored |
| `entity.upserted` | Knowledge graph entity updated |
| `run.completed` | Agent pipeline run finishes |
| `tool.executed` | Tool call completes (for metrics) |
| `vault.doc_upserted` | Vault document registered or updated |
| `delegate.sent` | Delegation dispatched to member |
| `delegate.completed` | Delegatee finishes successfully |
| `delegate.failed` | Delegation fails |
These events power the v3 enrichment pipeline (episodic memory, knowledge graph, vault indexing) independently from the WebSocket team events used by the UI.
## WebSocket Team Events
For UI real-time updates, team activity emits WebSocket events via `msgBus.Broadcast`. These are separate from the domain event bus and target connected dashboard clients.
When messages are sent, real-time events are broadcast to UI:
```json
{
"event": "team.message.sent",
"payload": {
"team_id": "550e8400-e29b-41d4-a716-446655440000",
"from_agent_key": "researcher_agent",
"from_display_name": "Research Expert",
"to_agent_key": "analyst_agent",
"to_display_name": "Data Analyst",
"message_type": "chat",
"preview": "Please review my findings...",
"user_id": "...",
"channel": "telegram",
"chat_id": "..."
}
}
```
### Task Lifecycle Events API
Task lifecycle events (create, assign, complete, approve, reject, comment, fail, etc.) are also available via the REST endpoint:
```
GET /v1/teams/{id}/events
```
This returns a paginated audit log of all task state changes for the team, useful for compliance review or building custom dashboards.
## Use Cases
**Member → Member**: "Task 123 is ready for your review. The data shows..."
**Member → Member**: "I'm blocked on step 2 — do you have the raw dataset I need?"
**Broadcast** (system-level only): "Changing priorities. Focus on tasks 1, 2, 5 instead of 3, 4."
> **Note**: Leads coordinate via `team_tasks`, not `team_message`. Use `team_tasks(action="progress")` to report status updates instead of direct messages.
## Auto-Fail on Loop Kill
If a member agent's run is terminated by the loop detector (stuck or infinite loop), the task automatically transitions to `failed`:
- The loop detector identifies stuck patterns — same tool calls with same args and results repeated, or read-only streaks without progress
- When critical level triggers, the run is killed and the team task manager marks the task as `failed`
- The lead agent is notified and can reassign or retry with updated instructions
This prevents infinite loops from blocking team progress — agents can safely attempt exploratory tasks without risk of permanent stall.
## Team Notification Settings
Team task events can be forwarded to chat channels. The default configuration is conservative — only high-signal events are on by default to reduce noise.
| Event | Default | Description |
|-------|---------|-------------|
| `dispatched` | ON | Task dispatched to a member |
| `new_task` | ON | New task created (human-initiated) |
| `completed` | ON | Task completed |
| `progress` | OFF | Member updates progress |
| `failed` | OFF | Task failed |
| `commented` | OFF | Task comment added |
| `slow_tool` | OFF | System alert when a tool call exceeds the adaptive threshold |
Delivery mode is `direct` by default (outbound channel). Set `mode: "leader"` to route all notifications through the lead agent.
Configure notifications in team settings:
```json
{
"notifications": {
"dispatched": true,
"new_task": true,
"completed": true,
"progress": false,
"failed": false,
"commented": false,
"slow_tool": false,
"mode": "direct"
}
}
```
## Best Practices
1. **Be concise**: Keep messages focused and actionable
2. **Use broadcasts for team-wide info**: Don't send identical messages to multiple members
3. **Direct message for discussion**: Back-and-forth coordination use direct messages
4. **Reference tasks**: Mention task IDs for context ("Task 123 is blocked by...")
5. **Check regularly**: Members should check their mailbox if waiting for updates
## Message Persistence
All messages are persisted to the database:
- Direct messages link sender → specific recipient
- Broadcasts link sender → NULL (means all members)
- Timestamps and read status tracked
- Full message history available for audit/review
---
# Delegation & Handoff
Delegation allows the lead to assign work to member agents via the task board. Handoff transfers conversation control between agents without interrupting the user's session.
## Agent Delegation Flow
Delegation works through the `team_tasks` tool — the lead creates a task with an assignee, and the system auto-dispatches it to the assigned member:
```mermaid
flowchart TD
LEAD["Lead receives user request"] --> CREATE["1. Create task on board
team_tasks(action=create,
assignee=member)"]
CREATE --> DISPATCH["2. System auto-dispatches
to assigned member"]
DISPATCH --> MEMBER["Member agent executes
in isolated session"]
MEMBER --> COMPLETE["3. Task auto-completed
with result"]
COMPLETE --> ANNOUNCE["4. Result announced
back to lead"]
subgraph "Parallel Delegation"
CREATE2["create task → member_A"] --> RUNA["Member A works"]
CREATE3["create task → member_B"] --> RUNB["Member B works"]
RUNA --> COLLECT["Results accumulate"]
RUNB --> COLLECT
COLLECT --> ANNOUNCE2["Single combined
announcement to lead"]
end
```
> **Note**: The `spawn` tool is for **self-clone subagents only** — it does not accept an `agent` parameter. To delegate to a team member, always use `team_tasks(action="create", assignee=...)`.
## Creating a Delegation Task
Use the `team_tasks` tool with `action: "create"` and a required `assignee`:
```json
{
"action": "create",
"subject": "Analyze the market trends in the Q1 report",
"description": "Focus on Q1 revenue data and competitor analysis",
"assignee": "analyst_agent"
}
```
The system validates and auto-dispatches:
- **`assignee` is required** — every task must be assigned to a team member
- **Assignee must be a team member** — non-members are rejected
- **Lead cannot self-assign** — prevents dual-session execution loops
- **Auto-dispatch**: after the lead's turn ends, pending tasks are dispatched to their assigned agents
**Guards enforced**:
- Max **3 dispatches** per task — auto-fails after 3 attempts to prevent infinite loops
- Task dispatched to lead agent is blocked and auto-failed
- Member requests (non-lead) can optionally require leader approval before dispatch
> **V2 leads**: Team V2 leads cannot manually create tasks before a spawn has been issued in the current turn. This prevents premature task creation that would break the structured orchestration flow.
## Parallel Delegation
Create multiple tasks in the same turn — they dispatch simultaneously after the turn:
```json
// Lead creates 2 tasks in one turn
{"action": "create", "subject": "Extract facts", "assignee": "analyst1"}
{"action": "create", "subject": "Extract opinions", "assignee": "analyst2"}
```
Results are collected via a **producer-consumer announce queue** (`BatchQueue[T]`) that merges staggered completions into a single LLM announcement run. This means the lead receives one combined message rather than separate interruptions per member — reducing token overhead significantly.
## Parallel Sub-Agent Enhancement (#600)
Beyond team member delegation, the lead can spawn **self-clone subagents** using the `spawn` tool for parallel workloads that don't require a specific team member:
```json
{"action": "spawn", "task": "Summarize the PDF report", "label": "pdf-summarizer"}
```
Key behaviors introduced in the parallel sub-agent enhancement:
### Smart Leader Delegation
The leader delegation prompt is **conditional** — it only activates when the situation genuinely requires delegation, rather than being forced on every spawn. This avoids wasted LLM turns when a direct response is more appropriate.
### `spawn(action=wait)` — WaitAll Orchestration
Block the parent until all spawned children complete:
```json
{"action": "wait", "timeout": 300}
```
- Parent turn pauses until all active subagents finish (or timeout expires)
- Enables coordinated multi-step workflows where the lead needs results before proceeding
- Default timeout: 300 seconds
### Auto-Retry with Linear Backoff
Subagent LLM failures trigger automatic retry. Configuration via `SubagentConfig`:
| Field | Default | Description |
|-------|---------|-------------|
| `MaxRetries` | `2` | Maximum retry attempts per subagent |
| Backoff | linear | Each retry waits `attempt × 2s` before re-running |
### Per-Edition Rate Limiting
Tenant-scoped concurrency limits on the Edition struct:
| Limit | Field | Description |
|-------|-------|-------------|
| Concurrent subagents | `MaxSubagentConcurrent` | Max simultaneous subagents per tenant |
| Spawn depth | `MaxSubagentDepth` | Max nesting depth (subagent spawning subagents) |
When limits are hit, the spawn is rejected with a clear error so the LLM can adjust.
### `subagent_tasks` Table (Migration 34)
Subagent task state is persisted to the `subagent_tasks` database table (migration 000034). The `SubagentTaskStore` interface with PostgreSQL implementation provides:
- Durable task tracking across restarts
- Write-through persistence from `SubagentManager`
- Token cost storage per task
### Token Cost Tracking
Per-subagent input and output token counts are accumulated and included in:
- The announce message delivered to the lead
- The `subagent_tasks` DB record for billing and observability
### Compaction Prompt Persistence
When the lead agent's context is compacted (summarized), pending subagent and team task state is preserved in the compaction prompt. Work continuity is maintained — the lead does not lose track of in-flight tasks after summarization.
### Telegram Commands
Two Telegram bot commands are available for monitoring subagent work:
| Command | Description |
|---------|-------------|
| `/subagents` | Lists all active subagent tasks with status |
| `/subagent ` | Shows detailed view of a specific subagent task from DB |
### Subagent Tool Restrictions
`team_tasks` is blocked inside subagents via `SubagentDenyAlways`. Subagents cannot create team tasks or perform team orchestration — only the lead can coordinate the team board.
## Auto-Completion & Artifacts
When a delegation finishes:
1. Linked task is marked `completed` with delegation result
2. Result summary is persisted
3. Media files (images, documents) are forwarded
4. Delegation artifacts stored with team context
5. Session cleaned up
**Announcement includes**:
- Results from each member agent
- Deliverables and media files
- Elapsed time statistics
- Guidance: present results to user, delegate follow-ups, or ask for revisions
## Delegation Search
When an agent has too many targets for static `AGENTS.md` (>15), use delegation search:
```json
{
"query": "data analysis and visualization",
"max_results": 5
}
```
Call the `delegate_search` tool with the above parameters.
**What it searches**:
- Agent name and key (full-text search)
- Agent description (full-text search)
- Semantic similarity (if embedding provider available)
**Result**:
```json
{
"agents": [
{
"agent_key": "analyst_agent",
"display_name": "Data Analyst",
"frontmatter": "Analyzes data and creates visualizations"
}
],
"count": 1
}
```
**Hybrid search**: Uses both keyword matching (FTS) and semantic embeddings for best results.
## Access Control: Agent Links
Each delegation link (lead → member) can have its own access control:
```json
{
"user_allow": ["user_123", "user_456"],
"user_deny": []
}
```
**Concurrency limits**:
- Per-link: configurable via `max_concurrent` on the agent link
- Per-agent: default 5 total concurrent delegations targeting any single member (configurable via agent's `max_delegation_load`)
When limits hit, error message: `"Agent at capacity. Try a different agent or handle it yourself."`
## Handoff: Conversation Transfer
Transfer conversation control to another agent without interrupting the user:
```json
{
"action": "transfer",
"agent": "specialist_agent",
"reason": "You need specialist expertise for the next part of your request",
"transfer_context": true
}
```
Call the `handoff` tool with the above parameters.
### What Happens
1. Routing override set: future messages from user go to target agent
2. Conversation context (summary) passed to target agent
3. Target agent receives handoff notification with context
4. Event broadcast to UI
5. User's next message routes to new agent
6. Deliverable workspace files copied to the target agent's team workspace
### Handoff Parameters
- `action`: `transfer` (default) or `clear`
- `agent`: Target agent key (required for `transfer`)
- `reason`: Why the handoff (required for `transfer`)
- `transfer_context`: Pass conversation summary (default true)
### Clear a Handoff
```json
{
"action": "clear"
}
```
Messages will route to default agent for this chat.
### Handoff Messaging
Handoff notification sent to the target agent:
```
[Handoff from researcher_agent]
Reason: You need specialist expertise for the next part of your request
Conversation context:
[summary of recent conversation]
Please greet the user and continue the conversation.
```
### Use Cases
- User's question becomes specialized → handoff to expert
- Agent reaches capacity → handoff to another instance
- Complex problem needs multiple specialties → handoff after partial solution
- Shift from research to implementation → handoff to engineer
## Evaluate Loop (Generator-Evaluator)
For iterative work, use the evaluate pattern with task creation:
```json
{"action": "create", "subject": "Generate initial proposal", "assignee": "generator_agent"}
// Wait for result, then:
{"action": "create", "subject": "Review proposal and provide feedback", "assignee": "evaluator_agent"}
// Generator refines based on feedback...
```
**Note**: The system does not enforce a maximum number of iterations for this pattern. Set your own limit in the lead's instructions to avoid infinite loops.
## Progress Notifications
For async delegations, the lead receives periodic grouped updates (if progress notifications are enabled for the team):
```
🏗 Your team is working on it...
- Data Analyst (analyst_agent): 2m15s
- Report Writer (writer_agent): 45s
```
**Interval**: 30 seconds. Enabled/disabled via team settings (`progress_notifications`).
## Best Practices
1. **Use `team_tasks` to delegate**: create tasks with `assignee` — system auto-dispatches
2. **Don't use `spawn` for delegation**: `spawn` is self-clone only, not for team members
3. **Create multiple tasks in one turn**: they dispatch in parallel after the turn ends
4. **Use `blocked_by`**: coordinate task ordering with dependencies
5. **Use `spawn(action=wait)`**: when lead needs all results before continuing
6. **Handle handoffs gracefully**: Notify user of transfer; pass context
7. **Set iteration limits in instructions**: Prevent infinite evaluate loops
---
# Custom Tools
> Give your agents new shell-backed capabilities at runtime — no recompile, no restart.
## Overview
Custom tools let you extend any agent with commands that run on your server. You define a name, a description the LLM uses to decide when to call the tool, a JSON Schema for the parameters, and a shell command template. GoClaw stores the definition in PostgreSQL, loads it at request time, and handles shell-escaping so the LLM cannot inject arbitrary shell syntax.
Tools can be **global** (available to all agents) or **scoped to a single agent** by setting `agent_id`.
```mermaid
sequenceDiagram
participant LLM
participant GoClaw
participant Shell
LLM->>GoClaw: tool_call {name: "deploy", args: {namespace: "prod"}}
GoClaw->>GoClaw: render template, shell-escape args
GoClaw->>GoClaw: check deny patterns
GoClaw->>Shell: sh -c "kubectl rollout restart ... --namespace='prod'"
Shell-->>GoClaw: stdout / stderr
GoClaw-->>LLM: tool_result
```
## Creating a Tool
### Via the HTTP API
```bash
curl -X POST http://localhost:8080/v1/tools/custom \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "deploy",
"description": "Roll out the latest image to a Kubernetes namespace. Use when the user asks to deploy or restart a service.",
"parameters": {
"type": "object",
"properties": {
"namespace": {
"type": "string",
"description": "Target Kubernetes namespace (e.g. production, staging)"
},
"deployment": {
"type": "string",
"description": "Name of the Kubernetes deployment"
}
},
"required": ["namespace", "deployment"]
},
"command": "kubectl rollout restart deployment/{{.deployment}} --namespace={{.namespace}}",
"timeout_seconds": 120,
"agent_id": "3f2a1b4c-0000-0000-0000-000000000000"
}'
```
**Required fields:** `name` and `command`. The name must be a slug (lowercase letters, numbers, hyphens only) and cannot conflict with a built-in or MCP tool name.
### Field reference
| Field | Type | Default | Description |
|---|---|---|---|
| `name` | string | — | Unique slug identifier |
| `description` | string | — | Shown to the LLM to trigger the tool |
| `parameters` | JSON Schema | `{}` | Parameters the LLM must provide |
| `command` | string | — | Shell command template |
| `working_dir` | string | agent workspace | Override working directory |
| `timeout_seconds` | int | 60 | Execution timeout |
| `agent_id` | UUID | null | Scope to one agent; omit for global |
| `enabled` | bool | true | Disable without deleting |
> **Three distinct timeouts — don't confuse them.** This per-custom-tool `timeout_seconds` (default 60) applies only to this one custom tool's command. It is separate from the host `exec` builtin tool's own `timeout_seconds` setting (also default 60, max 3600 — see [Tools Overview → Execution Timeout](/tools-overview)) and from the sandbox `sandbox_config.timeout_sec` (agent-level, default 300 — see [Sandbox](/sandbox)). A custom tool routed through the sandbox is still bounded by the sandbox timeout.
### Command templates
Use `{{.paramName}}` placeholders. GoClaw replaces them with shell-escaped values using simple string replacement — not Go's `text/template` engine, so template functions and pipelines are not supported. Every substituted value is single-quoted with embedded single-quotes escaped, so even a malicious LLM cannot break out of the argument.
```bash
# These placeholders are always treated as literal strings — no template logic
kubectl rollout restart deployment/{{.deployment}} --namespace={{.namespace}}
git -C {{.repo_path}} pull origin {{.branch}}
```
### Adding environment variables (secrets)
Secrets must be set via a separate `PUT` after creation — they cannot be included in the initial `POST`. They are encrypted with AES-256-GCM before storage and are **never returned by the API**.
```bash
curl -X PUT http://localhost:8080/v1/tools/custom/{id} \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"env": {
"KUBE_TOKEN": "eyJhbGc...",
"SLACK_WEBHOOK": "https://hooks.slack.com/services/..."
}
}'
```
The variables are injected only into the child process — they are not visible to the LLM or written to logs.
## Managing Tools
```bash
# List (paginated) — returns only enabled tools
GET /v1/tools/custom?limit=50&offset=0
# Filter by agent — returns only enabled tools for that agent
GET /v1/tools/custom?agent_id=
# Search by name or description (case-insensitive)
GET /v1/tools/custom?search=deploy
# Get single tool
GET /v1/tools/custom/{id}
# Update (partial — any field)
PUT /v1/tools/custom/{id}
# Delete
DELETE /v1/tools/custom/{id}
```
## Security
Every custom tool command is checked against the same **deny pattern list** as the built-in `exec` tool. Blocked categories include:
- Destructive file ops (`rm -rf`, `rm --recursive`, `dd if=`, `mkfs`, `shutdown`, `reboot`, fork bombs)
- Data exfiltration (`curl | sh`, `curl` with POST/PUT flags, `wget --post-data`, DNS tools: `nslookup`, `dig`, `host`, `/dev/tcp/` redirects)
- Reverse shells (`nc -e`, `ncat`, `socat`, `openssl s_client`, `telnet`, `mkfifo`, scripting language socket imports)
- Dangerous eval / code injection (`eval $`, `base64 -d | sh`)
- Privilege escalation (`sudo`, `su -`, `nsenter`, `unshare`, `mount`, `capsh`, `setcap`)
- Dangerous path operations (`chmod` on `/` paths, `chmod +x` in `/tmp`, `/var/tmp`, `/dev/shm`)
- Environment variable injection (`LD_PRELOAD=`, `DYLD_INSERT_LIBRARIES=`, `LD_LIBRARY_PATH=`, `BASH_ENV=`)
- Environment dumping (`printenv`, bare `env`, `env | ...`, `env > file`, `set`/`export -p`/`declare -x` dumps, `/proc/PID/environ`, `/proc/self/environ`)
- Container escape (`/var/run/docker.sock`, `/proc/sys/`, `/sys/kernel/`)
- Crypto mining (`xmrig`, `cpuminer`, stratum protocol)
- Filter bypass patterns (`sed /e`, `sort --compress-program`, `git --upload-pack=`, `grep --pre=`)
- Network reconnaissance (`nmap`, `masscan`, outbound `ssh`/`scp` with `@`)
- Persistence (`crontab`, writing to shell RC files like `.bashrc`, `.zshrc`)
- Process manipulation (`kill -9`, `killall`, `pkill`)
The check runs on the **fully rendered command** after all `{{.param}}` substitutions.
## Examples
### Check disk usage
```json
{
"name": "check-disk",
"description": "Report disk usage for a directory on the server.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path to check" }
},
"required": ["path"]
},
"command": "df -h {{.path}}"
}
```
### Tail application logs
```json
{
"name": "tail-logs",
"description": "Show the last N lines of an application log file.",
"parameters": {
"type": "object",
"properties": {
"service": { "type": "string", "description": "Service name, e.g. api, worker" },
"lines": { "type": "integer", "description": "Number of lines to show" }
},
"required": ["service", "lines"]
},
"command": "tail -n {{.lines}} /var/log/app/{{.service}}.log"
}
```
## Execution Ordering
When an agent emits several tool calls in one turn, only registered **read-only** built-in tools run in bounded parallel. Custom (shell-backed) tools are not registered as read-only, so they always execute **sequentially** — and any batch that mixes a custom tool with other calls runs entirely sequentially. This keeps shell-backed side effects predictable and ordered; it is not a setting you configure.
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| `name must be a valid slug` | Name has uppercase or spaces | Use lowercase, numbers, hyphens only |
| `tool name conflicts with existing built-in or MCP tool` | Clashes with `exec`, `read_file`, or MCP | Choose a different name |
| `command denied by safety policy` | Matches a deny pattern | Restructure command to avoid blocked ops |
| Tool not visible to agent | Wrong `agent_id` or `enabled: false` | Verify agent ID; re-enable if disabled |
| Execution timeout | Default 60 s too short for the task | Increase `timeout_seconds` |
## Built-in Tool: send_file
The `send_file` tool delivers an existing file in the workspace as an attachment — it does **not** create or modify files, only deliver them.
| Parameter | Required | Description |
|-----------|----------|-------------|
| `path` | Yes | File path (relative to workspace or absolute) |
| `caption` | No | Message to accompany the file |
**Example:** An agent has generated a report at `reports/summary.pdf` and then calls:
```json
{ "path": "reports/summary.pdf", "caption": "Here's this week's report" }
```
### DeliveredMedia Cross-Tool Dedup Contract
GoClaw maintains a `DeliveredMedia` tracker for the lifetime of an agent run. When the `message` tool sends `MEDIA:`, that path is marked as delivered. If the agent subsequently calls `send_file` on the same path, the call is a **no-op** — the file is not sent again.
This prevents duplicate delivery in the common pattern where an agent reflexively calls both `write_file(deliver=true)` (which auto-sends via `message`) and `send_file` on the same file.
> Source: `internal/tools/send_file.go`, `internal/tools/message.go`
---
# MCP Integration
> Connect any Model Context Protocol server to GoClaw and instantly give your agents its full tool catalog.
## Overview
MCP (Model Context Protocol) is an open standard that lets AI tools expose capabilities over a well-defined interface. Instead of writing a custom tool for every external service, you point GoClaw at an MCP server and it automatically discovers and registers all the tools that server exposes.
GoClaw supports three transports:
| Transport | When to use |
|---|---|
| `stdio` | Local process spawned by GoClaw (e.g. a Python script) |
| `sse` | Remote HTTP server using Server-Sent Events |
| `streamable-http` | Remote HTTP server using the newer streamable-HTTP transport |
```mermaid
graph LR
Agent --> Manager["MCP Manager"]
Manager -->|stdio| LocalProcess["Local process\n(e.g. python mcp_server.py)"]
Manager -->|sse| RemoteSSE["Remote SSE server\n(e.g. http://mcp:8000/sse)"]
Manager -->|streamable-http| RemoteHTTP["Remote HTTP server\n(e.g. http://mcp:8000/mcp)"]
Manager --> Registry["Tool Registry"]
Registry --> Agent
```
GoClaw runs a health-check loop every 30 seconds. A server is only marked disconnected after **3 consecutive ping failures** — transient network blips do not trigger a reconnect. When a server does go down, GoClaw reconnects with exponential backoff (initial delay 2 s, up to 10 attempts, capped at 60 s between retries).
## Registering an MCP Server
### Option 1 — config file (shared across all agents)
Add an `mcp_servers` block under the `tools` key in your `config.json`:
```json
{
"tools": {
"mcp_servers": {
"vnstock": {
"transport": "streamable-http",
"url": "http://vnstock-mcp:8000/mcp",
"tool_prefix": "vnstock_",
"timeout_sec": 30
},
"filesystem": {
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
"tool_prefix": "fs_",
"timeout_sec": 60
}
}
}
}
```
Config-based servers are loaded at startup and shared across all agents and users.
### Option 2 — Dashboard
Go to **Settings → MCP Servers → Add Server** and fill in the transport, URL or command, and optional prefix.
### Option 3 — HTTP API
```bash
curl -X POST http://localhost:8080/v1/mcp/servers \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "vnstock",
"transport": "streamable-http",
"url": "http://vnstock-mcp:8000/mcp",
"tool_prefix": "vnstock_",
"timeout_sec": 30,
"enabled": true
}'
```
### Server config fields
| Field | Type | Description |
|---|---|---|
| `transport` | string | `stdio`, `sse`, or `streamable-http` |
| `command` | string | Executable path (stdio only) |
| `args` | string[] | Arguments for the command (stdio only) |
| `env` | object | Environment variables for the process (stdio only) |
| `url` | string | Server URL (sse / streamable-http only) |
| `headers` | object | HTTP headers (sse / streamable-http only) |
| `tool_prefix` | string | Prefix prepended to all tool names from this server |
| `timeout_sec` | int | Per-call timeout (default 60 s) |
| `enabled` | bool | Set to `false` to disable without removing |
## Tool Prefixes
Two MCP servers might both expose a tool called `search`. GoClaw prevents collisions by prepending the `tool_prefix` to every tool name from that server:
```
vnstock_ → vnstock_search, vnstock_get_price, vnstock_get_financials
filesystem_ → filesystem_read_file, filesystem_write_file
```
If no prefix is set and a name collision is detected, GoClaw logs a warning (`mcp.tool.name_collision`) and skips the duplicate tool. Always set a prefix when connecting servers from different providers.
## Search Mode (large tool sets)
When the total number of MCP tools across all servers exceeds **40**, GoClaw automatically enters **hybrid mode**: the first 40 tools remain registered inline in the tool registry, while the remainder are deferred to search mode. In hybrid mode, the built-in `mcp_tool_search` tool is also exposed so the agent can find and activate the deferred tools on demand.
This keeps the tool list manageable when connecting many MCP servers. There is no configuration required — the switch is automatic.
### Lazy activation
In hybrid mode, if an agent calls a deferred MCP tool directly by name (without searching first), GoClaw **auto-activates** it. The tool is resolved from the MCP server, registered on the fly, and executed — no extra search step needed. This enables compatibility with agents that already know the tool name from prior context.
## Per-Agent Access Grants
DB-backed servers (added via Dashboard or API) support per-agent and per-user access control. You can also restrict which tools an agent can call:
```bash
# Grant agent access to a server, allow only specific tools
curl -X POST http://localhost:8080/v1/mcp/grants \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "3f2a1b4c-...",
"server_id": "a1b2c3d4-...",
"tool_allow": ["vnstock_get_price", "vnstock_get_financials"],
"tool_deny": []
}'
```
When `tool_allow` is non-empty, only those tools are visible to the agent. `tool_deny` removes specific tools even when the rest are allowed.
## Per-User Credential Servers (Deferred Loading)
Some MCP servers require per-user credentials (OAuth tokens, personal API keys). These servers are **not connected at startup**. Instead, GoClaw stores them during `LoadForAgent("")` as `userCredServers` and creates connections on a per-request basis via `pool.AcquireUser()` when a real user session arrives.
**How it works:**
1. At startup, `LoadForAgent("")` is called with no user context. Servers that `requireUserCreds` are stored in `userCredServers` — not connected.
2. When a user session starts, `LoadForAgent(userID)` is called. GoClaw resolves credentials for that specific user and connects the server for that session only.
3. The server and its tools are available only within that user's request context.
This means per-user credential servers are invisible in the global status endpoint but appear normally when accessed through a user session.
## Optional Tool Argument Stripping
LLMs often send empty strings or placeholder values (e.g. `""`, `"null"`, `"none"`, `"__OMIT__"`) for optional tool arguments instead of omitting them. This causes MCP servers to reject calls with invalid values (e.g. an empty string where a UUID is expected).
GoClaw automatically strips these values before forwarding the call. Required fields are always forwarded as-is. Optional fields with empty or placeholder values are removed from the call arguments.
No configuration required — stripping is always active for all MCP tool calls.
## Per-User Self-Service Access
Users can request access to an MCP server through the self-service portal. Requests are queued for admin approval. Once approved, the server is loaded for that user's sessions automatically via `LoadForAgent`.
## Checking Server Status
```bash
GET /v1/mcp/servers/status
```
Response:
```json
[
{
"name": "vnstock",
"transport": "streamable-http",
"connected": true,
"tool_count": 12
}
]
```
The `error` field is omitted when empty.
## Examples
### Add a stock data MCP server (docker-compose overlay)
```yaml
# docker-compose.vnstock-mcp.yml
services:
vnstock-mcp:
build:
context: ./vnstock-mcp
environment:
- MCP_TRANSPORT=http
- MCP_PORT=8000
- MCP_HOST=0.0.0.0
- VNSTOCK_API_KEY=${VNSTOCK_API_KEY}
networks:
- default
```
Then register it in `config.json`:
```json
{
"tools": {
"mcp_servers": {
"vnstock": {
"transport": "streamable-http",
"url": "http://vnstock-mcp:8000/mcp",
"tool_prefix": "vnstock_",
"timeout_sec": 30
}
}
}
}
```
Start the stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.vnstock-mcp.yml up -d
```
Your agents can now call `vnstock_get_price`, `vnstock_get_financials`, etc.
### Local stdio server (Python)
```json
{
"tools": {
"mcp_servers": {
"my-tools": {
"transport": "stdio",
"command": "python3",
"args": ["/opt/mcp/my_tools_server.py"],
"env": { "MY_API_KEY": "secret" },
"tool_prefix": "mytools_"
}
}
}
}
```
## Security: Prompt Injection Protection
MCP servers are external processes — a compromised or malicious server could attempt to inject instructions into the LLM by returning crafted tool results. GoClaw hardens against this automatically.
**How it works** (`internal/mcp/bridge_tool.go`):
1. **Marker sanitization** — Any `<<>>` markers already present in the result are replaced with `[[MARKER_SANITIZED]]` before wrapping.
2. **Content wrapping** — Every MCP tool result is wrapped in untrusted-content markers before being returned to the LLM:
```
<<>>
Source: MCP Server {server_name} / Tool {tool_name}
---
# Skills
> Package reusable knowledge into Markdown files and inject them into any agent's context automatically.
## Overview
A skill is a directory containing a `SKILL.md` file. When an agent runs, GoClaw reads the skill files that are in scope and injects their content into the system prompt under an `## Available Skills` section. The agent then uses that knowledge without you having to repeat it in every conversation.
Skills are useful for encoding recurring procedures, tool usage guides, domain knowledge, or coding conventions that the agent should always follow.
## SKILL.md Format
Each skill lives in its own directory. The directory name is the skill's **slug** — the unique identifier used for filtering and search.
```
~/.goclaw/skills/
└── code-reviewer/
└── SKILL.md
```
A `SKILL.md` file has an optional YAML frontmatter block followed by the skill content:
```markdown
## How to Review Code
When asked to review code, always check:
1. **Security** — SQL injection, XSS, hardcoded secrets
2. **Error handling** — all errors returned or logged
3. **Tests** — new logic has corresponding test coverage
Use `{baseDir}` to reference files alongside this SKILL.md:
- Checklist: {baseDir}/review-checklist.md
```
The `{baseDir}` placeholder is replaced at load time with the absolute path to the skill directory, so you can reference companion files.
> **Multiline blocks**: YAML frontmatter supports multiline strings for `description` using the `|` block scalar. This is useful for longer skill descriptions without hitting YAML line limits.
**Frontmatter fields:**
| Field | Description |
|---|---|
| `name` | Human-readable display name (defaults to directory name) |
| `description` | One-line summary used by `skill_search` to match queries |
## 6-Tier Hierarchy
GoClaw loads skills from six locations in priority order. A skill in a higher-priority location overrides one with the same slug from a lower one:
| Priority | Location | Source label |
|---|---|---|
| 1 (highest) | `/skills/` | `workspace` |
| 2 | `/.agents/skills/` | `agents-project` |
| 3 | `~/.agents/skills/` | `agents-personal` |
| 4 | `~/.goclaw/skills/` | `global` |
| 5 | `~/.goclaw/skills-store/` (DB-seeded, versioned) | `managed` |
| 6 (lowest) | Built-in (bundled with binary) | `builtin` |
Skills uploaded via the Dashboard are stored in `~/.goclaw/skills-store/` using a versioned subdirectory structure (`//SKILL.md`). They act at the `managed` level — above builtin but below the four file-system tiers. The loader always serves the highest-numbered version for each slug.
**Precedence example:** if you have a `code-reviewer` skill in both `~/.goclaw/skills/` and `/skills/`, the workspace version wins.
## Hot Reload
GoClaw watches all skill directories with `fsnotify`. When you create, modify, or delete a `SKILL.md`, changes are picked up within 500 ms — no restart required. The watcher bumps an internal version counter; agents compare their cached version on each request and reload skills if the counter changed.
```
# Drop a new skill in place — agents pick it up on the next request
mkdir ~/.goclaw/skills/my-new-skill
echo "---\nname: My Skill\ndescription: Does something useful.\n---\n\n## Instructions\n..." \
> ~/.goclaw/skills/my-new-skill/SKILL.md
```
## Slash Command Activation
Instead of waiting for the agent to discover a skill via `skill_search`, you can activate one explicitly by starting your chat message with a slash command. This is enabled by default.
```
/code-reviewer review this PR
/use sql-style format these queries
/list-skills
/help code-reviewer
```
**Supported forms:**
| Command | What it does |
|---|---|
| `/ ` | Activates the skill by slug (or display name) and treats the rest of the message as the skill's input |
| `/use ` | Same as above, explicit verb form (`/activate` also works) |
| `/list-skills` | Asks the agent to list all available skills |
| `/help ` | Asks the agent to explain a skill and how to use it |
When a skill is activated, GoClaw loads its `SKILL.md` into the current turn and scopes the agent to that skill for the request.
**Matching rules:**
- **Exact match** wins first — the slug or display name (case-insensitive) is matched directly. `/code-reviewer` matches the `code-reviewer` skill.
- **Partial (prefix) matching** is opt-in (off by default). When enabled, `/code` matches `code-reviewer` if it is the only prefix match.
- **Ambiguity guard** — if two skills tie on match strength (same-length match), the command is treated as *not matched* rather than guessing.
**No match:** if the requested skill is not found, GoClaw runs a fuzzy similarity search (edit-distance, up to 3 suggestions) and asks the agent to tell you the skill was not found and suggest the closest available skills. This suggestion behavior is on by default. If no similar skill exists, the message is treated as a normal prompt.
**Configuration** (all optional):
| Config key | Env / system config | Default | Effect |
|---|---|---|---|
| `skills.slash_commands.enabled` | `skills.slash_commands.enabled` | `true` | Master toggle |
| `skills.slash_commands.prefix` | `skills.slash_commands.prefix` | `/` | Single-character trigger prefix |
| `skills.slash_commands.partial_matching` | `skills.slash_commands.partial_matching` | `false` | Allow prefix matching |
| `skills.slash_commands.suggest_not_found` | `skills.slash_commands.suggest_not_found` | `true` | Suggest alternatives on no match |
> Messages that look like file paths (containing `/`, `\`, or `.` in the first token, e.g. `/etc/hosts`) are **not** treated as slash commands and pass through unchanged.
## Uploading via Dashboard
Go to **Skills → Upload** and drop a ZIP file. The ZIP can contain a **single skill** or **multiple skills** in one archive:
```
# Single skill — SKILL.md at root
my-skill.zip
└── SKILL.md
# Single skill — wrapped in one directory
my-skill.zip
└── code-reviewer/
├── SKILL.md
└── review-checklist.md
# Multi-skill ZIP — multiple skills in one upload
skills-bundle.zip
└── skills/
├── code-reviewer/
│ ├── SKILL.md
│ └── metadata.json
└── sql-style/
├── SKILL.md
└── metadata.json
```
Uploaded skills are stored in a versioned subdirectory structure under the managed skills directory (`~/.goclaw/skills-store/` by default):
```
~/.goclaw/skills-store///SKILL.md
```
Metadata (name, description, visibility, grants) lives in PostgreSQL; file content lives on disk. GoClaw always serves the highest-numbered version. Old versions are kept for rollback.
Skills uploaded via the Dashboard start with **internal** visibility — immediately accessible to any agent or user you grant access to.
### Upload Size Limit
Skill ZIP uploads (Dashboard and `POST /v1/skills/import`) are capped by a configurable size limit. The default is **20 MB**, and the effective value is always clamped to the **1–500 MB** range.
The limit is resolved in this precedence order (highest first):
1. **Tenant override** — system config key `skills.max_upload_size_mb` (set per tenant)
2. **SKILL.md frontmatter** — a `max_upload_size_mb` field in the skill's frontmatter
3. **Gateway default** — `GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB` env var or `skills.max_upload_size_mb` in the config file (falls back to 20 MB)
```yaml
# Raise the limit for one skill via its SKILL.md frontmatter
---
name: large-skill
description: Ships large reference assets.
max_upload_size_mb: 100
---
```
```bash
# Set the gateway-wide default via environment variable
export GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB=128
```
Uploads exceeding the resolved limit are rejected with an error like `skill ZIP size 42.0 MB exceeds 20 MB limit`.
## Importing via API
The `POST /v1/skills/import` endpoint accepts the same ZIP format as the Dashboard upload and supports both single and multi-skill archives.
**Standard import (JSON response):**
```bash
curl -X POST http://localhost:8080/v1/skills/import \
-H "Authorization: Bearer $TOKEN" \
-F "file=@skills-bundle.zip"
```
Returns a `SkillsImportSummary` JSON object:
```json
{
"skills_imported": 2,
"skills_skipped": 0,
"grants_applied": 3
}
```
**Streaming import with SSE progress (`?stream=true`):**
```bash
curl -X POST "http://localhost:8080/v1/skills/import?stream=true" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
-F "file=@skills-bundle.zip"
```
With `?stream=true`, the server sends Server-Sent Events (SSE) as each skill is processed:
```
event: progress
data: {"phase":"skill","status":"running","detail":"code-reviewer"}
event: progress
data: {"phase":"skill","status":"done","detail":"code-reviewer"}
event: complete
data: {"skills_imported":2,"skills_skipped":0,"grants_applied":3}
```
**Hash-based idempotency:** The upload endpoint uses a SHA-256 hash of the `SKILL.md` content for deduplication. If the same `SKILL.md` content is uploaded again (even packaged in a different ZIP), no new version is created — the existing version is kept unchanged. Only changes to the actual `SKILL.md` content trigger a new version.
> **Inline companion files via `skill_manage`:** Agents with skill evolution enabled can also create small text companion files inline (no ZIP) by passing a `files` object to the `skill_manage` tool — keyed by relative path, e.g. `references/guide.md`. Each inline text file is capped at **2 MB**, and the path validator rejects absolute paths, Windows drive paths (`C:\...`), null bytes, `..` traversal, `SKILL.md` overwrites, dotfiles/dotdirs, and system artifacts (e.g. `.git`). See [Agent Evolution](agent-evolution.md) for details.
## Selective Export & Download
You can export skills as a ZIP archive — either all skills or a hand-picked subset — from the Dashboard or the API.
**Preview before building** — `GET /v1/skills/export/preview` returns the counts that *would* be exported without building the archive, so the UI can show "N skills, M will be included" before you commit:
```bash
curl "http://localhost:8080/v1/skills/export/preview" \
-H "Authorization: Bearer $TOKEN"
```
**Export selected skills** — pass specific skill IDs to export only those, plus an optional `include_system` flag to include bundled/system skills:
```bash
# Export two specific skills, excluding system skills
curl "http://localhost:8080/v1/skills/export?ids=SKILL_ID_1,SKILL_ID_2&include_system=false" \
-H "Authorization: Bearer $TOKEN" -o skills-export.zip
```
For large exports, the server can build the archive asynchronously and stream Server-Sent Events as it works. On completion it returns a `download_url` pointing at a one-time token endpoint:
```
event: complete
data: {"download_url":"/v1/export/download/TOKEN"}
```
```bash
# Fetch the built archive with the returned token
curl "http://localhost:8080/v1/export/download/TOKEN" \
-H "Authorization: Bearer $TOKEN" -o skills-export.zip
```
**Dashboard:** the **Skills** page has a bulk-select toolbar — check the skills you want, then **Export selected** to download just those.
## Access Mode & Effective Access
Beyond visibility levels, each skill has an **access mode** that controls how broadly it is reachable. Admins read and set it via the access endpoints:
| Method | Path | Description |
|---|---|---|
| `GET` | `/v1/skills/{id}/access` | Show the skill's access mode and grants |
| `PATCH` | `/v1/skills/{id}/access` | Set access mode (`private`, `internal`, or `public`) |
| `GET` | `/v1/skills/{id}/access/effective` | Explain effective access for one skill, given an agent and user |
| `GET` | `/v1/skills/access/effective` | Explain effective access across all skills for an agent and user |
```bash
# Set a skill's access mode to internal
curl -X PATCH "http://localhost:8080/v1/skills/{id}/access" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"access_mode": "internal"}'
# Explain why an agent/user can or cannot reach a skill
curl "http://localhost:8080/v1/skills/{id}/access/effective?agent_id=AGENT_UUID&user_id=user@example.com" \
-H "Authorization: Bearer $TOKEN"
```
### Per-Skill Dependency Endpoints
In addition to the gateway-wide rescan, you can inspect and manage dependencies for a single skill:
| Method | Path | Description |
|---|---|---|
| `GET` | `/v1/skills/{id}/dependencies` | Current dependency status for the skill |
| `POST` | `/v1/skills/{id}/dependencies/scan` | Re-scan declared and detected dependencies |
| `POST` | `/v1/skills/{id}/dependencies/check` | Check whether each dependency resolves at runtime |
| `POST` | `/v1/skills/{id}/dependencies/install` | Install missing dependencies (master tenant) |
## Runtime Environment
Skills that use Python or Node.js run inside a Docker container with pre-installed packages.
### Pre-installed Packages
| Category | Packages |
|---|---|
| Python | `pypdf`, `openpyxl`, `pandas`, `python-pptx`, `markitdown` |
| Node.js (global npm) | `docx`, `pptxgenjs` |
| System tools | `python3`, `nodejs`, `pandoc`, `gh` (GitHub CLI) |
### Writable Runtime Directories
The container root filesystem is read-only. Agents install additional packages to writable volume-backed directories:
```
/app/data/.runtime/
├── pip/ ← PIP_TARGET (Python packages)
├── pip-cache/ ← PIP_CACHE_DIR
└── npm-global/ ← NPM_CONFIG_PREFIX (Node.js packages)
```
Packages installed at runtime persist across tool calls within the same container lifecycle.
### Security Constraints
| Constraint | Detail |
|---|---|
| `read_only: true` | Container rootfs is immutable; only volumes are writable |
| `/tmp` is `noexec` | Cannot execute binaries from tmpfs |
| `cap_drop: ALL` | No privilege escalation |
| Exec deny patterns | Blocks `curl \| sh`, reverse shells, crypto miners |
| `.goclaw/` denied | Exec tool blocks access to `.goclaw/` except `.goclaw/skills-store/` |
### What Agents Can/Cannot Do
Agents **can**: run Python/Node scripts, install packages via `pip3 install` or `npm install -g`, access files in `/app/workspace/` — including `.uploads/` for current user uploads and `.media/` for legacy media refs.
Agents **cannot**: write to system paths, execute binaries from `/tmp`, run blocked shell patterns (network tools, reverse shells).
## Bundled Skills
GoClaw ships seven core skills bundled inside the Docker image at `/app/bundled-skills/`. They are lowest priority — user-uploaded skills override them by slug.
| Skill | Purpose |
|---|---|
| `pdf` | Read, create, merge, split PDFs |
| `xlsx` | Read, create, edit spreadsheets |
| `docx` | Read, create, edit Word documents |
| `pptx` | Read, create, edit presentations |
| `skill-creator` | Create new skills |
| `workspace-organizing` | Keep agent workspaces tidy and discoverable — enforces a purpose-based folder convention and runs memory/Vault/knowledge-graph discovery before writing files to avoid duplicates |
| `goclaw` | Operate, inspect, administer, and debug a GoClaw gateway through the `goclaw` CLI/runtime — CLI discovery, gateway health/config diagnostics, agents, skills, MCP/tools, runtime packages, credentials, traces, sessions, channels, providers, and cron/jobs. Always inspects the live `goclaw --help` output first because command availability is version-dependent. |
Bundled skills are seeded into PostgreSQL on every gateway startup (hash-tracked, no re-import if unchanged). They are tagged `is_system = true` and `visibility = 'public'`.
### Dependency System
GoClaw auto-detects and installs missing skill dependencies:
1. **Scanner** — statically analyzes `scripts/` subdirectory for Python (`import X`, `from X import`) and Node.js (`require('X')`, `import from 'X'`) imports
2. **Checker** — verifies each import resolves at runtime via subprocess (`python3 -c "import X"` / `node -e "require.resolve('X')"`)
3. **Installer** — installs by prefix:
| Prefix | Effect |
|--------|--------|
| `pip:name` | `pip3 install` (Python package) |
| `npm:name` | `npm install -g` (Node.js package) |
| `system:name` | `apk add` via pkg-helper (system package) |
| `github:owner/repo[@tag]` | GitHub Releases installer — admin-only, SHA256-verified, ELF-validated. Binary lands in `/app/data/.runtime/bin/` (on `$PATH`). |
Example SKILL.md frontmatter using `github:`:
```yaml
---
name: my-skill
description: Does things using ripgrep and gh CLI.
deps:
- github:BurntSushi/ripgrep@14.1.0
- github:cli/cli@v2.40.0
- pip:requests
---
```
The `github:` installer fetches the release from GitHub Releases, auto-selects the `linux` + arch-matching asset (amd64 / arm64), verifies SHA256 if the publisher ships `checksums.txt`, validates ELF magic bytes, and extracts to `/app/data/.runtime/bin/`. If no `@tag` is specified, the latest release is used.
Dep checks run in a background goroutine at startup (non-blocking). Skills with missing deps are archived automatically; they are re-activated after deps are installed. You can also trigger a rescan via **Skills → Rescan Deps** in the Dashboard or `POST /v1/skills/rescan-deps`.
## Built-in Skill Tools
GoClaw provides three built-in tools that agents use to discover and activate skills at runtime.
### skill_search
Agents search skills using `skill_search`. The search uses a **BM25 index** built from each skill's name and description, with optional hybrid search (BM25 + vector embeddings) when an embedding provider is configured.
```
# The agent calls this tool internally — you don't call it directly
skill_search(query="how to review a pull request", max_results=5)
```
The tool returns ranked results with name, description, location path, and score. After receiving results, the agent calls `use_skill` then `read_file` to load the skill content.
The index is rebuilt whenever the loader's version counter is bumped (i.e., after any hot-reload event or startup).
### use_skill
A lightweight observability marker tool. The agent calls `use_skill` before reading a skill's file, so skill activation is visible in traces and real-time events. It does not load any content itself.
```
use_skill(name="code-reviewer")
# then:
read_file(path="/path/to/code-reviewer/SKILL.md")
```
### publish_skill
Agents can register a local skill directory into the system database using `publish_skill`. The directory must contain a `SKILL.md` with a `name` in its frontmatter. The skill is automatically granted to the calling agent after publishing.
```
publish_skill(path="./skills/my-skill")
```
The skill is stored with `private` visibility and auto-granted to the calling agent. Admins can later grant it to other agents or promote visibility via the Dashboard or API.
## Granting Skills to Agents (Managed Mode)
Skills published via `publish_skill` start with **private** visibility. Skills uploaded via the Dashboard start with **internal** visibility. Either way, you must **grant** a skill to an agent before it is injected into that agent's context.
### Via Dashboard
1. Go to **Skills** in the sidebar
2. Click the skill you want to grant
3. Under **Agent Grants**, select the agent and click **Grant**
4. The skill is now injected into that agent's context on the next request
To revoke, toggle off the agent in the grants list.
### Via API
Grant a skill to an agent:
```bash
curl -X POST http://localhost:8080/v1/skills/{id}/grants/agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent_id": "AGENT_UUID", "version": 1}'
```
To grant with manage rights (see [Agent-Manage Grants](#agent-manage-grants) below), add `"can_manage": true`:
```bash
curl -X POST http://localhost:8080/v1/skills/{id}/grants/agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"agent_id": "AGENT_UUID", "version": 1, "can_manage": true}'
```
List all agent grants for a skill:
```bash
curl http://localhost:8080/v1/skills/{id}/grants/agent \
-H "Authorization: Bearer $TOKEN"
```
Response:
```json
{
"grants": [
{
"agent_id": "019...",
"agent_key": "my-agent",
"display_name": "My Agent",
"pinned_version": 1,
"granted_by": "admin@example.com",
"can_manage": false
}
]
}
```
Revoke an agent grant:
```bash
curl -X DELETE http://localhost:8080/v1/skills/{id}/grants/agent/{agent_id} \
-H "Authorization: Bearer $TOKEN"
```
Grant a skill to a specific user (so it appears in their agent sessions):
```bash
curl -X POST http://localhost:8080/v1/skills/{id}/grants/user \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_id": "user@example.com"}'
```
Revoke a user grant:
```bash
curl -X DELETE http://localhost:8080/v1/skills/{id}/grants/user/{user_id} \
-H "Authorization: Bearer $TOKEN"
```
### Agent-Manage Grants
By default, only the skill owner and tenant admins can edit or delete a skill. The `can_manage` flag on `skill_agent_grants` lets you delegate that capability to a specific agent without promoting it to admin.
When `can_manage = true`, the grantee agent can:
- Update the skill's name, description, tags, and visibility via `PUT /v1/skills/{id}`
- Delete the skill via `DELETE /v1/skills/{id}`
- Publish new versions using the `skill_manage` tool inside conversations
**Tenant scope enforcement** — both the skill and the agent must belong to the same tenant. Grant writes that cross tenant boundaries are rejected at the store layer (`verifySkillGrantScope`). A cleanup migration (`000067`) removes any pre-existing cross-tenant rows from earlier versions.
**Visibility auto-promotion** — granting a `private` skill to any agent automatically promotes it to `internal` so the grantee can access it. Revoking the last agent grant demotes it back to `private` atomically.
### Tenant-Scoped Grant Isolation
In multi-tenant deployments, skill grant operations are tenant-scoped:
- `POST /v1/skills/{id}/grants/agent` verifies that the target agent belongs to the same tenant as the skill. Cross-tenant grant attempts return `404` ("agent not found") to avoid leaking tenant topology.
- System skills (`is_system = true`) bypass tenant filtering — they are accessible to all tenants.
- `GET /v1/skills/{id}/grants/agent` returns only grants within the requesting tenant's scope.
### Visibility Levels
| Level | Who can access |
|---|---|
| `private` | Only the skill owner (uploader) |
| `internal` | Agents and users explicitly granted access |
| `public` | All agents and users |
## Examples
### Workspace-scoped SQL style guide
```
my-project/
└── skills/
└── sql-style/
└── SKILL.md
```
```markdown
---
name: SQL Style Guide
description: Team conventions for writing PostgreSQL queries in this project.
---
## SQL Conventions
- Use `$1, $2` positional parameters — never string interpolation
- Always use `RETURNING id` on INSERT
- Table and column names: snake_case
- Never use `SELECT *` in application queries
```
### Global "be concise" reminder
```
~/.goclaw/skills/
└── concise-responses/
└── SKILL.md
```
```markdown
---
name: Concise Responses
description: Keep all responses short, bullet-pointed, and actionable.
---
Always:
- Lead with the answer, not the explanation
- Use bullet points for lists of 3 or more items
- Keep code examples under 20 lines
```
## Agent Injection Thresholds
GoClaw decides whether to embed skills inline in the system prompt or fall back to `skill_search`:
| Condition | Mode |
|---|---|
| `≤ 40 skills` AND estimated tokens `≤ 5000` | **Inline** — skills injected as XML in system prompt |
| `> 40 skills` OR estimated tokens `> 5000` | **Search** — agent uses `skill_search` tool instead |
Token estimate: `(len(name) + len(description) + 10) / 4` per skill (~100–150 tokens each).
Disabled skills (`enabled = false`) are excluded from both inline and search injection.
### Listing Archived Skills
Skills with missing dependencies are set to `status = 'archived'` and are still visible in the Dashboard. You can list them via `GET /v1/skills?status=archived` or the `skills.list` WebSocket RPC method (which returns `enabled`, `status`, and `missing_deps` fields for each skill).
## Skill Evolution
When `skill_evolve` is enabled in agent config, agents gain a `skill_manage` tool that allows them to create, update, and version skills from within conversations — a learning loop where the agent improves its own knowledge base. When `skill_evolve` is **off** (the default), the `skill_manage` tool is hidden from the LLM's tool list entirely.
See [Agent Evolution](agent-evolution.md) for full details on the `skill_manage` tool and the evolution workflow.
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Skill not appearing in agent | Wrong directory structure (SKILL.md not inside a subdirectory) | Ensure path is `//SKILL.md` |
| Changes not picked up | Watcher not started (non-Docker setups) | Restart GoClaw; verify `skills watcher started` in logs |
| Lower-priority skill used instead of yours | Name collision — slug exists at a higher tier | Use a unique slug, or place your skill at a higher-priority location |
| `skill_search` returns no results | Index not built yet (first request) or no description in frontmatter | Add a `description` to frontmatter; index rebuilds on next hot-reload |
| ZIP upload fails | No `SKILL.md` found in ZIP | Place `SKILL.md` at ZIP root, inside one top-level directory, or use the multi-skill `skills//SKILL.md` layout |
## What's Next
- [MCP Integration](/mcp-integration) — connect external tool servers
- [Custom Tools](/custom-tools) — add shell-backed tools to your agents
- [Scheduling & Cron](/scheduling-cron) — run agents on a schedule
---
# Scheduling & Cron
> Trigger agent turns automatically — once, on a repeating interval, or on a cron expression.
## Overview
GoClaw's cron service lets you schedule any agent to run a message on a fixed schedule. Jobs are persisted to PostgreSQL, so they survive restarts. The scheduler checks for due jobs every second and executes them in parallel goroutines.
Three schedule types are available:
| Type | Field | Description |
|---|---|---|
| `at` | `atMs` | One-time execution at a specific Unix timestamp (ms) |
| `every` | `everyMs` | Repeating interval in milliseconds |
| `cron` | `expr` | Standard 5-field cron expression (parsed by gronx) |
One-time (`at`) jobs are automatically deleted after they run.
```mermaid
stateDiagram-v2
[*] --> Active: job created / enabled
Active --> Running: due time reached
Running --> Active: reschedule (every / cron)
Running --> Deleted: one-time (at) after run
Active --> Paused: enabled set to false
Paused --> Active: enabled set to true
```
## Creating a Job
### Via the Dashboard
Go to **Cron → New Job**, fill in the schedule, the message the agent should process, and (optionally) a delivery channel.
### Via the Gateway WebSocket API
GoClaw uses WebSocket RPC. Send a `cron.create` method call:
```json
{
"method": "cron.create",
"params": {
"name": "daily-standup-summary",
"schedule": {
"kind": "cron",
"expr": "0 9 * * 1-5",
"tz": "Asia/Ho_Chi_Minh"
},
"message": "Summarize yesterday's GitHub activity and post a standup update.",
"deliver": true,
"channel": "telegram",
"to": "123456789",
"agentId": "3f2a1b4c-0000-0000-0000-000000000000"
}
}
```
### Via the `cron` built-in tool (agent-created jobs)
Agents can schedule their own follow-up tasks during a conversation using the `cron` tool with `action: "add"`. GoClaw automatically strips leading tab indentation from the `description` field and validates parameters to prevent malformed job creation.
```json
{
"action": "add",
"job": {
"name": "check-server-health",
"schedule": { "kind": "every", "everyMs": 300000 },
"message": "Check if the API server is responding and alert me if it's down."
}
}
```
### Via the CLI
```bash
# List jobs (active only)
goclaw cron list
# List all jobs including disabled
goclaw cron list --all
# List as JSON
goclaw cron list --json
# Enable or disable a job
goclaw cron toggle true
goclaw cron toggle false
# Delete a job
goclaw cron delete
```
## Job Fields
| Field | Type | Description |
|---|---|---|
| `name` | string | Slug label — lowercase letters, numbers, hyphens only (e.g. `daily-report`). Must be unique per agent and tenant — duplicate names are automatically deduplicated |
| `agentId` | string | Agent UUID to run the job (omit for default agent) |
| `enabled` | bool | `true` = active, `false` = paused |
| `schedule.kind` | string | `at`, `every`, or `cron` |
| `schedule.atMs` | int64 | Unix timestamp in ms (for `at`) |
| `schedule.everyMs` | int64 | Interval in ms (for `every`) |
| `schedule.expr` | string | 5-field cron expression (for `cron`) |
| `schedule.tz` | string | IANA timezone — applies to **all** schedule kinds (`at`, `every`, `cron`), not just cron expressions. Omit to use the gateway default timezone |
| `message` | string | Text the agent receives as its input |
| `stateless` | bool | Run without session history — saves tokens for simple scheduled tasks. Default `false` |
| `deliver` | bool | `true` = deliver result to a channel; `false` = agent processes silently. Auto-defaults to `true` when the job is created from a real channel (Telegram, etc.). Delivery is still suppressed if the output contains the `NO_REPLY` token — see [NO_REPLY suppression](#no_reply-suppression) |
| `channel` | string | Target channel: `telegram`, `discord`, etc. Auto-filled from context when `deliver` is `true` |
| `to` | string | Chat ID or recipient identifier. Auto-filled from context when `deliver` is `true` |
| `deleteAfterRun` | bool | Auto-set to `true` for `at` jobs; can be set manually on any job |
| `wakeHeartbeat` | bool | When `true`, triggers an immediate [Heartbeat](heartbeat.md) run after the cron job completes. Useful for jobs that should report status via the heartbeat system |
## Schedule Expressions
### `at` — run once at a specific time
```json
{
"kind": "at",
"atMs": 1741392000000
}
```
The job is deleted after it fires. If `atMs` is already in the past when the job is created, it will never run.
### `every` — repeating interval
```json
{ "kind": "every", "everyMs": 3600000 }
```
Common intervals:
| Expression | Interval |
|---|---|
| `60000` | Every minute |
| `300000` | Every 5 minutes |
| `3600000` | Every hour |
| `86400000` | Every 24 hours |
### `cron` — 5-field cron expression
```json
{ "kind": "cron", "expr": "30 8 * * *", "tz": "UTC" }
```
5-field format: `minute hour day-of-month month day-of-week`
| Expression | Meaning |
|---|---|
| `0 9 * * 1-5` | 09:00 on weekdays |
| `30 8 * * *` | 08:30 every day |
| `0 */4 * * *` | Every 4 hours |
| `0 0 1 * *` | Midnight on the 1st of each month |
| `*/15 * * * *` | Every 15 minutes |
Expressions are validated at creation time using [gronx](https://github.com/adhocore/gronx). Invalid expressions are rejected with an error.
## Managing Jobs
GoClaw exposes cron management via WebSocket RPC methods. The available methods are:
| Method | Description |
|---|---|
| `cron.list` | List jobs (`includeDisabled: true` to include disabled) |
| `cron.create` | Create a new job |
| `cron.update` | Update a job (`jobId` + `patch` object) |
| `cron.delete` | Delete a job (`jobId`) |
| `cron.toggle` | Enable or disable a job (`jobId` + `enabled: bool`) |
| `cron.run` | Trigger a job manually (`jobId` + `mode: "force"` or `"due"`) |
| `cron.runs` | View run history (`jobId`, `limit`, `offset`) |
| `cron.status` | Scheduler status (active job count, running flag) |
**Examples:**
```json
// Pause a job
{ "method": "cron.toggle", "params": { "jobId": "", "enabled": false } }
// Update schedule
{ "method": "cron.update", "params": { "jobId": "", "patch": { "schedule": { "kind": "cron", "expr": "0 10 * * *" } } } }
// Manual trigger (run regardless of schedule)
{ "method": "cron.run", "params": { "jobId": "", "mode": "force" } }
// View run history (last 20 entries by default)
{ "method": "cron.runs", "params": { "jobId": "", "limit": 20, "offset": 0 } }
```
## Job Lifecycle
- **Active** — `enabled: true`, `nextRunAtMs` is set; will fire when due.
- **Paused** — `enabled: false`, `nextRunAtMs` is cleared; skipped by the scheduler.
- **Running** — executing the agent turn; `nextRunAtMs` is cleared until execution completes to prevent duplicate runs.
- **Completed (one-time)** — `at` jobs are deleted from the store after firing.
The scheduler checks jobs every 1 second. Due jobs are dispatched in parallel goroutines. Run logs are persisted to the `cron_run_logs` PostgreSQL table and accessible via the `cron.runs` method.
Failed jobs record `lastStatus: "error"` and `lastError` with the message. The job stays enabled and will retry on its next scheduled tick (unless it was a one-time `at` job).
## NO_REPLY suppression
Even when a job is configured to deliver (`deliver: true` with a `channel` and `to`), GoClaw will **not** send the result to the channel if the agent's output contains the `NO_REPLY` token.
- The match is **whole-word** and **case-insensitive** (`NO_REPLY`, `no_reply`, etc.), so the token must stand on its own — it won't match when embedded inside a longer word.
- When suppressed, the run still completes and is logged; only the channel delivery is skipped (an info-level log records that delivery was suppressed).
This lets a scheduled agent decide at runtime that there is nothing worth sending — for example, a health check that only replies when something is wrong:
```json
{
"name": "api-health-check",
"schedule": { "kind": "every", "everyMs": 300000 },
"message": "Check https://api.example.com/health. If it returns 200, reply with exactly NO_REPLY. Otherwise describe what is wrong.",
"deliver": true,
"channel": "telegram",
"to": "123456789"
}
```
The `NO_REPLY` token follows the same convention used across GoClaw — see [How GoClaw Works](../core-concepts/how-goclaw-works.md) and [Sessions & History](../core-concepts/sessions-and-history.md).
## Retry — Exponential Backoff
When a cron job execution fails, GoClaw automatically retries with exponential backoff before logging it as an error.
| Parameter | Default |
|-----------|---------|
| Max retries | 3 |
| Base delay | 2 seconds |
| Max delay | 30 seconds |
| Jitter | ±25% |
**Formula:** `delay = min(base × 2^attempt, max) ± 25% jitter`
Example sequence: fail → 2s → retry → fail → 4s → retry → fail → 8s → retry → fail → logged as error.
## Scheduler Lanes & Queue Behavior
GoClaw routes all requests — cron jobs, user chats, delegations — through named scheduler lanes with configurable concurrency.
### Lane defaults
| Lane | Concurrency | Purpose |
|------|:-----------:|---------|
| `main` | 30 | Primary user chat sessions |
| `subagent` | 50 | Sub-agents spawned by the main agent |
| `team` | 100 | Agent team/delegation executions |
| `cron` | 30 | Scheduled cron jobs |
All values are configurable via environment variables (`GOCLAW_LANE_MAIN`, `GOCLAW_LANE_SUBAGENT`, `GOCLAW_LANE_TEAM`, `GOCLAW_LANE_CRON`).
### Session queue defaults
Each session maintains its own message queue. When the queue is full, the oldest message is dropped to make room for the new one.
| Parameter | Default | Description |
|-----------|---------|-------------|
| `mode` | `queue` | Queue mode (see below) |
| `cap` | 10 | Max messages in the queue |
| `drop` | `old` | Drop oldest on overflow |
| `debounce_ms` | 800 | Collapse rapid messages within this window |
### Queue modes
| Mode | Behavior |
|------|----------|
| `queue` | FIFO — messages wait until a run slot is available |
| `followup` | Same as `queue` — messages are queued as follow-ups |
| `interrupt` | Cancel the active run, drain the queue, start the new message immediately |
### Adaptive throttle
When a session's conversation history exceeds **60% of the context window**, the scheduler automatically reduces concurrency to 1 for that session. This prevents context window overflow during high-throughput periods.
### /stop and /stopall
`/stop` and `/stopall` commands are intercepted **before** the 800ms debouncer so they are never merged with an incoming user message.
| Command | Behavior |
|---------|----------|
| `/stop` | Cancel the oldest active task; others continue |
| `/stopall` | Cancel all active tasks and drain the queue |
## Examples
### Daily news briefing via Telegram
```json
{
"name": "morning-briefing",
"schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "Asia/Ho_Chi_Minh" },
"message": "Give me a brief summary of today's tech news headlines.",
"deliver": true,
"channel": "telegram",
"to": "123456789"
}
```
### Periodic health check (silent — agent decides whether to alert)
```json
{
"name": "api-health-check",
"schedule": { "kind": "every", "everyMs": 300000 },
"message": "Check https://api.example.com/health and alert me on Telegram if it returns a non-200 status.",
"deliver": false
}
```
### One-time reminder
```json
{
"name": "meeting-reminder",
"schedule": { "kind": "at", "atMs": 1741564200000 },
"message": "Remind me that the quarterly review meeting starts in 15 minutes.",
"deliver": true,
"channel": "telegram",
"to": "123456789"
}
```
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Job never runs | `enabled: false` or `atMs` is in the past | Check job state; re-enable or update schedule |
| `invalid cron expression` on create | Malformed expr (e.g. 6-field Quartz syntax) | Use standard 5-field cron |
| `invalid timezone` | Unknown IANA zone string | Use a valid zone from the IANA tz database, e.g. `America/New_York` |
| Job runs but agent gets no message | `message` field is empty | Set a non-empty `message` |
| `name` validation error | Name not a valid slug | Use lowercase letters, numbers, and hyphens only (e.g. `daily-report`) |
| Duplicate job name | Same `name` already exists for this agent and tenant | Job names must be unique per `(agent_id, tenant_id, name)` — each agent/tenant pair enforces this as a unique constraint (migration 047). Use a different name or update the existing job |
| Duplicate executions | Clock skew between restarts (edge case) | The scheduler clears `next_run_at` in the DB before dispatch; on restart, stale jobs are recomputed automatically |
| Run log is empty | Job hasn't fired yet | Trigger manually via `cron.run` method with `mode: "force"` |
## Evolution Cron (v3 Background Worker)
GoClaw runs an internal background cron for the v3 agent evolution engine. This is not a user-managed job — it starts automatically when the gateway starts.
| Cadence | Action |
|---------|--------|
| 1 minute after startup (warm-up) | Initial suggestion analysis for all evolution-enabled agents |
| Every 24 hours | Re-run suggestion analysis (`SuggestionEngine.Analyze`) for all active agents with `evolution_metrics: true` |
| Every 7 days | Evaluate applied suggestions; roll back if quality metrics regressed (`EvaluateApplied`) |
**How it works:**
1. On startup, `runEvolutionCron` starts as a background goroutine in `cmd/gateway_evolution_cron.go`
2. It lists all active agents and checks the `evolution_metrics` v3 flag on each
3. For eligible agents, `SuggestionEngine.Analyze` generates improvement suggestions based on conversation metrics
4. Weekly, `EvaluateApplied` checks applied suggestions against guardrail thresholds and auto-rolls back regressions
**To enable evolution for an agent**, set `evolution_metrics: true` in the agent's `other_config` via the dashboard. No config.json changes are needed.
> The evolution cron runs with a 5-minute per-cycle timeout. Errors for individual agents are logged at debug level and do not abort the cycle for other agents.
## What's Next
- [Heartbeat](heartbeat.md) — proactive periodic check-ins with smart suppression
- [Custom Tools](/custom-tools) — give agents shell commands to run during scheduled turns
- [Skills](/skills) — inject domain knowledge so scheduled agents are more effective
- [Sandbox](/sandbox) — isolate code execution during scheduled agent runs
---
# Heartbeat
> Proactive periodic check-ins — agents execute a configurable checklist on a timer and report results to your channels.
## Overview
Heartbeat is an application-level monitoring feature: your agent wakes up on a schedule, runs through a HEARTBEAT.md checklist, and delivers results to a messaging channel (Telegram, Discord, Feishu). If everything looks fine, the agent can suppress delivery entirely using a `HEARTBEAT_OK` token — keeping your channels quiet when there's nothing to report.
This is **not** a WebSocket keep-alive. It's a user-facing proactive monitoring system with smart suppression, active-hours windows, and per-heartbeat model overrides.
## Quick Setup
### Via the Dashboard
1. Open **Agent Detail** → **Heartbeat** tab
2. Click **Configure** (or **Setup** if not yet configured)
3. Set interval, delivery channel, and write your HEARTBEAT.md checklist
4. Click **Save** — the agent will run on schedule
### Via the agent tool
Agents can self-configure heartbeat during a conversation:
```json
{
"action": "set",
"enabled": true,
"interval": 1800,
"channel": "telegram",
"chat_id": "-100123456789",
"active_hours": "08:00-22:00",
"timezone": "Asia/Ho_Chi_Minh"
}
```
## HEARTBEAT.md Checklist
HEARTBEAT.md is an agent context file that defines what the agent should do during each heartbeat run. It lives alongside your other context files (BOOTSTRAP.md, SKILLS.md, etc.).
**How to write one:**
- List concrete tasks using your agent's tools — not just reading the list back
- Use `HEARTBEAT_OK` at the end when all checks pass and there's nothing to deliver
- Keep it focused: short checklists run faster and cost less
**Example HEARTBEAT.md:**
```markdown
# Heartbeat Checklist
1. Check https://api.example.com/health — if non-200, alert immediately
2. Query the DB for any failed jobs in the last 30 minutes — summarize if any
3. If all clear, respond with: HEARTBEAT_OK
```
The agent receives your checklist in its system prompt with explicit instructions to execute the tasks using its tools, not just repeat the checklist text.
## Configuration
| Field | Type | Default | Description |
|---|---|---|---|
| `enabled` | bool | `false` | Master on/off switch |
| `interval_sec` | int | 1800 | Seconds between runs (minimum: 300) |
| `prompt` | string | — | Custom check-in message (default: "Execute your heartbeat checklist now.") |
| `provider_id` | UUID | — | LLM provider override for heartbeat runs |
| `model` | string | — | Model override (e.g. `gpt-4o-mini`) |
| `isolated_session` | bool | `true` | Fresh session per run, auto-deleted after |
| `light_context` | bool | `false` | Skip context files, inject only HEARTBEAT.md |
| `max_retries` | int | 2 | Retry attempts on failure (0–10, exponential backoff) |
| `active_hours_start` | string | — | Window start in `HH:MM` format |
| `active_hours_end` | string | — | Window end in `HH:MM` format (supports midnight wrap) |
| `timezone` | string | — | IANA timezone for active hours (default: UTC) |
| `channel` | string | — | Delivery channel: `telegram`, `discord`, `feishu` |
| `chat_id` | string | — | Target chat or group ID |
| `ack_max_chars` | int | — | Reserved for future threshold logic (not yet active) |
## Scheduling & Wake Modes
The heartbeat ticker polls for due agents every 30 seconds. There are four ways a heartbeat run is triggered:
| Mode | Trigger |
|---|---|
| **Ticker poll** | Background goroutine runs `ListDue(now)` every 30s |
| **Manual test** | "Test" button in Dashboard UI or `{"action": "test"}` agent tool call |
| **RPC test** | `heartbeat.test` WebSocket RPC call |
| **Cron wake** | Cron job with `wake_heartbeat: true` completes → triggers immediate run |
**Stagger mechanism:** When you first enable a heartbeat, the initial `next_run_at` is offset by a deterministic amount (FNV-1a hash of the agent UUID, capped at 10% of `interval_sec`). This prevents multiple agents enabled at the same time from all firing at once. Subsequent runs advance by a flat interval without stagger.
## Execution Flow
```mermaid
flowchart TD
A[Ticker due] --> B{Active hours?}
B -- outside window --> Z1[Skip: active_hours]
B -- inside window --> C{Agent busy?}
C -- has active sessions --> Z2[Skip: queue_busy\nno next_run_at advance]
C -- idle --> D{HEARTBEAT.md?}
D -- empty or missing --> Z3[Skip: empty_checklist]
D -- found --> E[Emit 'running' event]
E --> F[Build system prompt\nwith checklist]
F --> G[Run agent loop\nmax_retries + 1 attempts]
G -- all failed --> Z4[Log error, advance next_run_at]
G -- success --> H{Contains HEARTBEAT_OK?}
H -- yes --> I[Suppress: increment suppress_count]
H -- no --> J[Deliver to channel/chatID]
```
**Steps:**
1. **Active hours filter** — If outside the configured window, skip and advance `next_run_at`
2. **Queue-aware check** — If agent has active chat sessions, skip *without* advancing `next_run_at` (retried on next 30s poll)
3. **Checklist load** — Reads HEARTBEAT.md from agent context files; skips if empty
4. **Emit event** — Broadcasts `heartbeat: running` to all WebSocket clients
5. **Build prompt** — Injects checklist + suppression rules into the agent's extra system prompt
6. **Run agent loop** — Exponential backoff: immediate → 1s → 2s → ... up to `max_retries + 1` total attempts
7. **Suppression check** — If response contains `HEARTBEAT_OK` anywhere, delivery is cancelled
8. **Deliver** — Publishes to the configured `channel` + `chat_id` via the message bus
## Smart Suppression
When the agent's response contains the token `HEARTBEAT_OK` anywhere, the **entire response is suppressed** — nothing is sent to the channel. This keeps your chat quiet during routine "all clear" runs.
**Use `HEARTBEAT_OK` when:**
- All monitoring checks passed
- No anomalies detected
- The checklist doesn't ask you to send content
**Do NOT use `HEARTBEAT_OK` when:**
- The checklist explicitly asks for a report, summary, joke, greeting, etc.
- Any check failed or needs attention
The `suppress_count` field tracks how often suppression fires, giving you a signal-to-noise ratio for your checklist quality.
## Provider & Model Override
You can run heartbeats on a cheaper model than your agent's default:
```json
{
"action": "set",
"provider_name": "openai",
"model": "gpt-4o-mini"
}
```
This is applied only during heartbeat runs. Your agent's regular conversations continue using its configured model. The override is useful when heartbeat frequency is high and you want to manage costs.
## Light Context Mode
By default, the agent loads all its context files (BOOTSTRAP.md, SKILLS.md, INSTRUCTIONS.md, etc.) before each run. Enabling `light_context` skips all of them and injects only HEARTBEAT.md:
```json
{ "action": "set", "light_context": true }
```
This reduces context size, speeds up execution, and lowers token costs — ideal when the checklist is self-contained and doesn't rely on general agent instructions.
## Delivery Targets
The heartbeat delivers results to the `channel` + `chat_id` pair you configure. GoClaw can suggest targets automatically by inspecting your agent's session history:
- In the Dashboard → **Delivery** tab → click **Fetch targets**
- Via RPC: `heartbeat.targets` returns known `(channel, chatId, title, kind)` tuples
When an agent self-configures heartbeat using the `set` action from within a real channel conversation, the delivery target is auto-filled from the current conversation context.
## Agent Tool
The `heartbeat` built-in tool lets agents read and manage their own heartbeat configuration:
| Action | Requires Permission | Description |
|---|---|---|
| `status` | No | One-line status: enabled, interval, run counts, last/next times |
| `get` | No | Full configuration as JSON |
| `set` | Yes | Create or update config (upsert) |
| `toggle` | Yes | Enable or disable |
| `set_checklist` | Yes | Write HEARTBEAT.md content |
| `get_checklist` | No | Read HEARTBEAT.md content |
| `test` | No | Trigger an immediate run |
| `logs` | No | View paginated run history |
Permission for mutation actions (`set`, `toggle`, `set_checklist`) falls back to: deny list → allow list → agent owner → always allowed in system context (cron, subagent).
## RPC Methods
| Method | Description |
|---|---|
| `heartbeat.get` | Fetch heartbeat config for an agent |
| `heartbeat.set` | Create or update config (upsert) |
| `heartbeat.toggle` | Enable or disable (`agentId` + `enabled: bool`) |
| `heartbeat.test` | Trigger immediate run via wake channel |
| `heartbeat.logs` | Paginated run history (`limit`, `offset`) |
| `heartbeat.checklist.get` | Read HEARTBEAT.md content |
| `heartbeat.checklist.set` | Write HEARTBEAT.md content |
| `heartbeat.targets` | List known delivery targets from session history |
## Dashboard UI
**HeartbeatCard** (Agent Detail → overview) — Quick status overview: enabled toggle, interval, active hours, delivery target, model override badge, last run time, next run countdown, run/suppress counts, and last error.
**HeartbeatConfigDialog** — Five sections:
1. **Basic** — Enable switch, interval slider (5–300 min), custom prompt
2. **Schedule** — Active hours start/end (HH:MM), timezone selector
3. **Delivery** — Channel dropdown, chat ID, fetch-targets button
4. **Model & Context** — Provider/model selectors, isolated session toggle, light context toggle, max retries
5. **Checklist** — HEARTBEAT.md editor with character count, load/save buttons
**HeartbeatLogsDialog** — Paginated run history table: timestamp, status badge (ok / suppressed / error / skipped), duration, token usage, summary or error text.
## Heartbeat vs Cron
| Aspect | Heartbeat | Cron |
|---|---|---|
| Purpose | Health monitoring + proactive check-in | General-purpose scheduled tasks |
| Schedule types | Fixed interval only | `at`, `every`, `cron` (5-field expr) |
| Minimum interval | 300 seconds | No minimum |
| Checklist source | HEARTBEAT.md context file | `message` field in job |
| Suppression | `HEARTBEAT_OK` token | None |
| Queue-aware | Skips if agent busy (no advance) | Runs regardless |
| Model override | Configurable per-heartbeat | Not available |
| Light context | Configurable | Not available |
| Active hours | Built-in HH:MM + timezone | Not built-in |
| Cardinality | One per agent | Many per agent |
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Heartbeat never fires | `enabled: false` or no `next_run_at` | Enable via Dashboard or `{"action": "toggle", "enabled": true}` |
| Runs but nothing delivered | `HEARTBEAT_OK` in all responses | Check checklist logic; use HEARTBEAT_OK only when truly silent |
| Skipped every time | Agent is always busy | Heartbeat waits for idle; reduce user conversation load or check session leaks |
| Outside active hours | `active_hours` window misconfigured | Verify `timezone` matches your IANA zone and HH:MM values |
| `interval_sec < 300` error | Minimum is 5 minutes | Set `interval_sec` to 300 or higher |
| No delivery targets | No session history for agent | Start a conversation in the target channel first; targets are auto-discovered |
| Error status, no detail | All retries failed | Check `heartbeat.logs` for `error` field; verify tools and provider are reachable |
## What's Next
- [Scheduling & Cron](scheduling-cron.md) — general-purpose scheduled tasks and cron expressions
- [Custom Tools](custom-tools.md) — give your agent shell commands and APIs to call during heartbeat runs
- [Sandbox](sandbox.md) — isolate code execution during agent runs
---
# Sandbox
> Run agent shell commands inside an isolated Docker container so untrusted code never touches your host.
## Overview
When sandbox mode is enabled, every tool call that touches the filesystem or runs a command (`exec`, `read_file`, `write_file`, `list_files`, `edit`) is routed into a Docker container instead of running directly on the host. The container is ephemeral, network-isolated, and heavily restricted by default — dropped capabilities, read-only root filesystem, tmpfs for `/tmp`, and a 512 MB memory cap.
If Docker is unavailable at runtime, GoClaw returns an error and refuses to execute — it will **not** fall back to unsandboxed host execution.
```mermaid
graph LR
Agent -->|exec / read_file / write_file\nlist_files / edit| Tools
Tools -->|sandbox enabled| DockerManager
DockerManager -->|Get or Create| Container["Docker Container\ngoclaw-sbx-*"]
Container -->|docker exec| Command
Command -->|stdout/stderr| Tools
Tools -->|result| Agent
Tools -->|Docker unavailable| Error["Error\n(sandbox required)"]
```
## Sandbox Modes
Set `GOCLAW_SANDBOX_MODE` (or `agents.defaults.sandbox.mode` in config) to one of:
| Mode | Which agents are sandboxed |
|---|---|
| `off` | None — all commands run on host (default) |
| `non-main` | All agents except `main` and `default` |
| `all` | Every agent |
## Container Scope
Scope controls how containers are reused across requests:
| Scope | Container lifetime | Best for |
|---|---|---|
| `session` | One container per session | Maximum isolation (default) |
| `agent` | One container shared across all sessions for an agent | Persistent state within an agent |
| `shared` | One container for all agents | Lowest overhead |
## Default Security Profile
Out of the box, every sandbox container runs with:
| Setting | Value |
|---|---|
| Root filesystem | Read-only (`--read-only`) |
| Capabilities | All dropped (`--cap-drop ALL`) |
| New privileges | Blocked (`--security-opt no-new-privileges`) |
| tmpfs mounts | `/tmp`, `/var/tmp`, `/run` |
| Network | Disabled (`--network none`) |
| Memory limit | 512 MB |
| CPUs | 1.0 |
| Execution timeout | 300 seconds |
| Max output | 1 MB (stdout + stderr combined) |
| Container prefix | `goclaw-sbx-` |
| Working directory | `/workspace` |
If a command produces more than 1 MB of output, the output is truncated and `...[output truncated]` is appended.
## Configuration
All settings can be provided as environment variables or in `config.json` under `agents.defaults.sandbox`.
### Environment variables
```bash
GOCLAW_SANDBOX_MODE=all
GOCLAW_SANDBOX_IMAGE=goclaw-sandbox:bookworm-slim
GOCLAW_SANDBOX_WORKSPACE_ACCESS=rw # none | ro | rw
GOCLAW_SANDBOX_SCOPE=session # session | agent | shared
GOCLAW_SANDBOX_MEMORY_MB=512
GOCLAW_SANDBOX_CPUS=1.0
GOCLAW_SANDBOX_TIMEOUT_SEC=300
GOCLAW_SANDBOX_NETWORK=false
```
### config.json
```json
{
"agents": {
"defaults": {
"sandbox": {
"mode": "all",
"image": "goclaw-sandbox:bookworm-slim",
"workspace_access": "rw",
"scope": "session",
"memory_mb": 512,
"cpus": 1.0,
"timeout_sec": 300,
"network_enabled": false,
"read_only_root": true,
"max_output_bytes": 1048576,
"idle_hours": 24,
"max_age_days": 7,
"prune_interval_min": 5
}
}
}
}
```
### Full config reference
| Field | Type | Default | Description |
|---|---|---|---|
| `mode` | string | `off` | `off`, `non-main`, or `all` |
| `image` | string | `goclaw-sandbox:bookworm-slim` | Docker image to use |
| `workspace_access` | string | `rw` | Mount workspace as `none`, `ro`, or `rw` |
| `scope` | string | `session` | Container reuse: `session`, `agent`, or `shared` |
| `memory_mb` | int | 512 | Memory limit in MB |
| `cpus` | float | 1.0 | CPU quota |
| `timeout_sec` | int | 300 | Per-command timeout in seconds |
| `network_enabled` | bool | false | Enable container networking |
| `read_only_root` | bool | true | Mount root filesystem read-only |
| `tmpfs_size_mb` | int | 0 | Default size for tmpfs mounts (0 = Docker default) |
| `user` | string | — | Container user, e.g. `1000:1000` or `nobody` |
| `max_output_bytes` | int | 1048576 | Max stdout+stderr capture per exec (1 MB) |
| `setup_command` | string | — | Shell command run once after container creation |
| `env` | object | — | Extra environment variables injected into the container |
| `idle_hours` | int | 24 | Prune containers idle longer than N hours |
| `max_age_days` | int | 7 | Prune containers older than N days |
| `prune_interval_min` | int | 5 | Background prune check interval (minutes) |
Security hardening defaults (`--cap-drop ALL`, `--tmpfs /tmp:/var/tmp:/run`, `--security-opt no-new-privileges`) are applied automatically and are not overridable via config.
## Workspace Access
The workspace directory is mounted at `/workspace` inside the container:
- `none` — no filesystem mount; container has no access to your project files
- `ro` — read-only mount; agent can read files but cannot write
- `rw` — read-write mount (default); agent can read and write project files
## Container Lifecycle
1. **Creation** — on first exec call for a scope key, `docker run -d ... sleep infinity` starts a long-lived container.
2. **Execution** — each command runs via `docker exec` inside the running container.
3. **Pruning** — a background goroutine checks every `prune_interval_min` minutes and destroys containers that have been idle longer than `idle_hours` or exist longer than `max_age_days`.
4. **Destruction** — `docker rm -f ` is called on pruning, session end, or `ReleaseAll` at shutdown.
Container names follow the pattern `goclaw-sbx-`, where the scope key is derived from the session key, agent ID, or `"shared"` depending on the configured scope.
## Setup with docker-compose
Build the sandbox image first:
```bash
docker build -t goclaw-sandbox:bookworm-slim -f Dockerfile.sandbox .
```
Then add the sandbox overlay to your compose command:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.sandbox.yml \
up
```
The `docker-compose.sandbox.yml` overlay mounts the Docker socket and sets sandbox environment variables:
```yaml
services:
goclaw:
build:
args:
ENABLE_SANDBOX: "true"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GOCLAW_SANDBOX_MODE=all
- GOCLAW_SANDBOX_IMAGE=goclaw-sandbox:bookworm-slim
- GOCLAW_SANDBOX_WORKSPACE_ACCESS=rw
- GOCLAW_SANDBOX_SCOPE=session
- GOCLAW_SANDBOX_MEMORY_MB=512
- GOCLAW_SANDBOX_CPUS=1.0
- GOCLAW_SANDBOX_TIMEOUT_SEC=300
- GOCLAW_SANDBOX_NETWORK=false
# Allow Docker socket access from the goclaw container
cap_drop: []
cap_add:
- NET_BIND_SERVICE
security_opt: []
group_add:
- ${DOCKER_GID:-999}
```
> **Security note:** Mounting the Docker socket gives the GoClaw container control over the host Docker daemon. Only use sandbox mode in environments where you trust the GoClaw process itself.
## Examples
### Sandbox only sub-agents, not the main agent
```bash
GOCLAW_SANDBOX_MODE=non-main
```
The `main` and `default` agents run commands on the host. All other agents (sub-agents, specialized workers) are sandboxed.
### Read-only workspace with custom setup
```json
{
"agents": {
"defaults": {
"sandbox": {
"mode": "all",
"workspace_access": "ro",
"setup_command": "pip install -q pandas numpy",
"memory_mb": 1024,
"timeout_sec": 120
}
}
}
}
```
The `setup_command` runs once after the container is created. Use it to pre-install dependencies so they are available on every subsequent `exec`.
### Check active sandbox containers
GoClaw does not expose a public HTTP endpoint for sandbox stats. You can inspect running containers directly with Docker:
```bash
docker ps --filter "label=goclaw.sandbox=true"
```
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| `docker not available` in logs | Docker daemon not running or socket not mounted | Start Docker; ensure socket is mounted in compose |
| Commands fail with sandbox error | Docker unavailable at exec time | Start Docker; ensure socket is mounted in compose; sandbox mode does not fall back to host |
| `docker run failed` on container creation | Image not found or insufficient permissions | Build the sandbox image; check `DOCKER_GID` |
| Output truncated at 1 MB | Command produced very large output | Increase `max_output_bytes` or pipe output to a file |
| Container not cleaned up after session | Pruner not running or `idle_hours` too high | Lower `idle_hours`; check `sandbox pruning started` in logs |
| Write fails inside container | `workspace_access: ro` or `read_only_root: true` with no tmpfs | Switch to `rw` or add a tmpfs mount for the target path |
## Team-Root Workspace Boundaries
When an agent runs in team-root mode (part of an agent team), it has **read access** to peer-chat workspaces across the team. However, read-allowed and write-allowed paths are kept separate:
| Operation | Path set used |
|---|---|
| `read_file`, `list_files` | Read-allowed — includes team root and peer-chat workspaces |
| `write_file`, `edit` | Write-allowed — restricted to the agent's own chat workspace only |
| `exec` / `shell` | Write-allowed — cwd resolution uses the more restrictive write-allowed prefixes |
This asymmetry prevents a team-root agent from mutating peer-chat workspaces even though it can read them. Absolute paths in shell commands are also bounded by the write-allowed prefix set, closing the path that allowed cross-chat mutations via `cd` or absolute argument injection.
> **Note:** This workspace boundary applies regardless of sandbox mode. Sandbox mode controls whether commands run inside Docker; team-root path restrictions are enforced at the tool layer before Docker is involved.
## What's Next
- [Custom Tools](/custom-tools) — define shell tools that also benefit from sandbox isolation
- [Exec Approval](/exec-approval) — require human approval before any command runs, sandboxed or not
- [Scheduling & Cron](/scheduling-cron) — run sandboxed agent turns on a schedule
---
# Media Generation
> Generate images, videos, and audio directly from your agents — with automatic provider fallback chains.
## Overview
GoClaw includes three built-in media generation tools: `create_image`, `create_video`, and `create_audio`. Each tool uses a **provider chain** — a prioritized list of AI providers that GoClaw tries in order. If the first provider fails or times out, it automatically falls back to the next one.
Generated files are saved to `workspace/generated/{YYYY-MM-DD}/` and returned as `MEDIA:` paths that channels render natively (inline images, video players, audio messages).
Generated files are verified after writing — if the file doesn't exist on disk, the tool reports an error instead of returning a broken path.
## Video Generation
**Tool:** `create_video`
**Default provider chain:** Gemini → MiniMax → OpenRouter
**Default models:** Gemini `veo-3.1-lite-generate-preview`, MiniMax `MiniMax-Hailuo-2.3`, OpenRouter `google/veo-3.1-lite-generate-preview`
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | required | Text description of the video |
| `duration` | int | `8` | Duration in seconds: `4`, `6`, or `8` |
| `aspect_ratio` | string | `16:9` | `16:9` or `9:16` |
| `image_path` | string | — | Path to a workspace image to use as starting frame (image-to-video). Omit for text-to-video. Supported formats: PNG, JPEG, WebP, GIF. Max 20 MB. |
| `filename_hint` | string | — | Short descriptive filename without extension (e.g. `cat-playing-piano`) |
### Image-to-Video
Provide an `image_path` to generate a video starting from a reference image. The image is encoded as base64 and sent to the provider. When using image-to-video mode, duration is fixed at **8 seconds** (API constraint).
**Example agent prompt:** *"Animate this product photo with a slow zoom and subtle lighting changes"* (with `image_path` pointing to a workspace image)
> **Note:** Not all providers support image-to-video. Gemini (Veo 3.1 Lite) supports it natively. Unsupported providers in the chain are skipped automatically.
Video generation is slow — both Gemini and MiniMax poll up to ~6 minutes. The timeout per provider defaults to 120 seconds but can be increased via chain settings.
---
## Audio Generation
**Tool:** `create_audio`
**Default provider:** MiniMax (music, model `music-2.5+`), ElevenLabs (sound effects)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | required | Description or lyrics |
| `type` | string | `music` | `music` or `sound_effect` |
| `duration` | int | — | Duration in seconds — applies to sound effects only; music length is determined by lyrics length |
| `lyrics` | string | — | Lyrics for music generation. Use `[Verse]`, `[Chorus]` tags |
| `instrumental` | bool | `false` | Instrumental only (no vocals) |
| `provider` | string | — | Force a specific provider (e.g. `minimax`) |
- **Sound effects** route directly to ElevenLabs (max 30 seconds)
- **Music** uses MiniMax as the default provider with a 300-second timeout. Duration is controlled by lyrics length, not the `duration` parameter
---
## Native Image Generation (Codex + OpenAI-compat)
Codex and OpenAI-compatible providers support **native** image generation — an `image_generation` tool object is attached directly to the LLM request rather than going through the `create_image` provider chain.
### Tri-Level Gate
All three conditions must be satisfied for `image_generation` to activate:
| Gate | Source | Default |
|------|--------|---------|
| Provider capability (`ProviderCapabilities.ImageGeneration`) | Auto-set `true` for Codex and OpenAI-compat | — |
| `AgentConfig.AllowImageGeneration` | `other_config.allow_image_generation` in agent config | `true` |
| Header opt-out | Client sends `x-goclaw-no-image-gen` to disable per-request | not sent = allowed |
To disable native image generation for a specific agent:
```json
{
"other_config": {
"allow_image_generation": false
}
}
```
To opt out per-request, the client sends the header:
```
x-goclaw-no-image-gen: 1
```
### Partial-Image Streaming
During image generation, Codex emits `response.image_generation_call.partial_image` events over the SSE stream. GoClaw surfaces these events so clients can display incremental previews before the final image is complete.
### Storage and Metadata
Image files are saved to `{workspace}/media/{sha256}.{ext}` (e.g. `media/a3f7bc12.png`). For PNG files, GoClaw embeds a tEXt metadata chunk immediately before IEND:
| Chunk key | Value |
|-----------|-------|
| `Description` | User prompt |
| `Software` | `goclaw` |
This metadata supports audit and prompt traceability directly from the image file.
### Codex Pool Routing
When a Codex pool is configured, image generation requests go through the `create_image` chain with a **per-modality round-robin counter** — the chat counter and image counter operate independently. This prevents image generation from skewing the chat load distribution.
> Source: `internal/providers/codex_native_image.go`, `internal/providers/openai_image_url.go`, `internal/agent/media.go`, `internal/agent/png_metadata.go`, `internal/providers/capabilities.go`
---
## Customizing the Provider Chain
Override the default chain per agent via `builtin_tools.settings` in the agent config:
```json
{
"builtin_tools": {
"settings": {
"create_image": {
"providers": [
{
"provider": "openai",
"model": "gpt-image-1",
"enabled": true,
"timeout": 60,
"max_retries": 2
},
{
"provider": "minimax",
"enabled": true,
"timeout": 30
}
]
}
}
}
}
```
**Chain fields:**
| Field | Default | Description |
|-------|---------|-------------|
| `provider` | — | Provider name (must have API key configured) |
| `model` | auto | Model override |
| `enabled` | `true` | Skip this entry if `false` |
| `timeout` | `120` | Timeout per attempt in seconds |
| `max_retries` | `2` | Retries before moving to next provider |
The chain executes sequentially — first success wins, last error is returned if all fail.
---
## Image Analysis (read_image)
The `read_image` tool can be configured with a dedicated vision provider chain. When configured, images are routed to the vision provider instead of being attached inline to the main LLM — useful when your main model lacks vision capability or you want a specialized model for image analysis.
Supports the same chain format as `create_*` tools:
```json
{
"builtin_tools": {
"settings": {
"read_image": {
"providers": [
{ "provider": "gemini", "model": "gemini-2.5-flash", "enabled": true },
{ "provider": "openai", "model": "gpt-4o", "enabled": true }
]
}
}
}
}
```
Also supports the legacy flat format:
```json
{
"builtin_tools": {
"settings": {
"read_image": {
"provider": "gemini"
}
}
}
}
```
If no `read_image` chain is configured, images are attached inline to the main LLM as usual.
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | required | What you want to know about the image(s) |
| `path` | string | — | Optional path to an image in the workspace (generated images or attachments) |
| `url` | string | — | Optional URL to an image hosted online |
`path` and `url` are **mutually exclusive** — passing both returns an error. If neither is given, the tool analyzes images already attached to the conversation.
> **Provider note:** Anthropic and `claude-cli` providers cannot analyze images directly from a URL — they require base64-encoded image data. If a URL-only image is routed to one of them, that provider errors and the chain falls back to the next provider. Gemini, OpenRouter, and DashScope accept image URLs directly. URLs are SSRF-validated before any fetch.
---
## Video Analysis (read_video)
The `read_video` tool analyzes video files using a video-capable provider chain (Gemini → OpenRouter by default). Use it when the conversation contains `` tags, or point it at a workspace file or a hosted URL.
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | required | What to analyze — e.g. "Summarize the key scenes", "What text appears on screen?" |
| `media_id` | string | — | Optional specific `media_id` from a `` tag. If omitted, uses the most recent video |
| `url` | string | — | Optional URL to a video file hosted online |
`media_id` and `url` are **mutually exclusive**. Local video files are capped at **100 MB**.
> **URL streaming:** For video URLs, GoClaw pipes the stream from the URL through the **Gemini File API** rather than downloading the whole file locally first. URLs are SSRF-validated and the resolved IP is pinned for the upload. At the provider layer, image and video parts are now distinct media-content types (`ImageContent` vs `VideoContent`).
The same chain-override format as `create_*` and `read_image` applies under `builtin_tools.settings.read_video`.
---
## Document Analysis (read_document)
The `read_document` tool extracts and analyzes documents — **PDF, DOCX, and images of documents** — using a document-capable provider chain (Gemini → Anthropic → claude-cli → OpenRouter → DashScope by default).
### Parameters
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `prompt` | string | required | What to analyze — e.g. "Extract all tables", "What does page 3 say?" |
| `media_id` | string | — | Optional specific `media_id` from a `` tag |
| `path` | string | — | Optional file path from a `` tag |
Unlike `read_image` and `read_video`, `read_document` has **no** `url` parameter. Plain-text formats (JSON, CSV, Markdown, HTML, etc.) are returned directly without an LLM call. Files are capped at **20 MB**.
### Local-First Extraction (opt-in)
By default, documents go straight to the cloud vision chain. You can opt into local extraction that runs `pdftotext` (PDF) and `pandoc --sandbox` (DOCX) on the host *before* any cloud call, via the `document_parser` config block:
```json
{
"local_first": false,
"max_pages": 200,
"timeout_sec": 30,
"min_text_len": 16
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `local_first` | `false` | Enable local extraction. Requires `pdftotext`/`pandoc` on PATH — present in the `full` Docker variant or builds with `ENABLE_FULL_SKILLS=true` |
| `max_pages` | `200` | PDF page limit; passed to `pdftotext -l` |
| `timeout_sec` | `30` | Per-extraction timeout; the process group is killed on timeout |
| `min_text_len` | `16` | Minimum characters (after trim) for a successful extraction; shorter output triggers cloud fallback |
Config is captured at startup (not hot-reloaded); binary availability is re-checked per call, so a runtime install is detected without a restart. Any miss — disabled, unsupported MIME, missing binary, timeout, or too-little text (e.g. a scanned image-only PDF) — falls back transparently to the cloud vision chain. PDF extraction uses `pdftotext -l `; DOCX uses `pandoc --sandbox` so an untrusted document cannot fetch remote resources during conversion.
---
## Required API Keys
Media generation uses your existing provider API keys. Make sure the relevant providers are configured:
| Provider | Used for | Config location |
|----------|----------|-----------------|
| OpenAI | Image, Video | `providers` section |
| OpenRouter | Image, Video | `providers` section |
| Gemini | Image, Video | `providers` section |
| MiniMax | Image, Video, Audio | `providers` section |
| DashScope | Image | `providers` section |
| ElevenLabs | Audio (sound effects) | `tts.providers.elevenlabs` |
---
## File Size Limit
Downloaded media files are capped at **200 MB**. Files exceeding this limit will fail.
---
## What's Next
- [TTS & Voice](/tts-voice) — Text-to-speech for agent replies
- [Custom Tools](/custom-tools) — Build your own tools
- [Provider Overview](/providers-overview) — Configure API keys
---
# TTS Voice
> Add voice replies to your agents — pick from five providers and control exactly when audio fires.
## Overview
GoClaw's TTS system converts agent text replies into audio and delivers them as voice messages on supported channels (e.g. Telegram voice bubbles). You configure a primary provider, set an auto-apply mode, and GoClaw handles the rest — stripping markdown, truncating long text, and choosing the right audio format per channel.
Five providers are available:
| Provider | Key | Requires |
|----------|-----|---------|
| OpenAI | `openai` | API key |
| ElevenLabs | `elevenlabs` | API key |
| Microsoft Edge TTS | `edge` | `edge-tts` CLI (free) — always available as fallback |
| MiniMax | `minimax` | API key + Group ID |
| Google Gemini TTS | `gemini` | API key |
## Provider Setup
### OpenAI
```json
{
"tts": {
"provider": "openai",
"auto": "inbound",
"openai": {
"api_key": "sk-...",
"model": "gpt-4o-mini-tts",
"voice": "alloy"
}
}
}
```
Available voices: `alloy`, `ash`, `ballad`, `coral`, `echo`, `fable`, `onyx`, `nova`, `sage`, `shimmer`, `verse`, `marin`, `cedar`. Note: `ballad`, `verse`, `marin`, `cedar` are only compatible with `gpt-4o-mini-tts`.
Supported models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts` (default).
#### OpenAI Advanced Params
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `speed` | range | 1.0 | 0.25–4.0; agent-overridable |
| `response_format` | enum | `mp3` | mp3, opus, aac, flac, wav, pcm |
| `instructions` | text | — | Style prompt; `gpt-4o-mini-tts` only (advanced) |
---
### ElevenLabs
```json
{
"tts": {
"provider": "elevenlabs",
"auto": "always",
"elevenlabs": {
"api_key": "xi-...",
"voice_id": "pMsXgVXv3BLzUgSXRplE",
"model_id": "eleven_multilingual_v2"
}
}
}
```
Find voice IDs in your [ElevenLabs voice library](https://elevenlabs.io/voice-library). Default model: `eleven_multilingual_v2`.
#### ElevenLabs Model Variants
| Model ID | Characteristic | Best For |
|----------|---------------|---------|
| `eleven_v3` | Latest flagship (Nov 2025), highest quality | Premium voice, complex speech |
| `eleven_multilingual_v2` | High-quality, 29 languages | Default; multilingual content |
| `eleven_turbo_v2_5` | Cost-optimized, fast | High-volume, budget-conscious |
| `eleven_flash_v2_5` | Lowest latency, 32 languages | Real-time / interactive use |
Only these four model IDs are accepted — unknown IDs are rejected at the gateway boundary.
#### ElevenLabs Advanced Params
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `voice_settings.stability` | range | 0.5 | 0–1; voice consistency |
| `voice_settings.similarity_boost` | range | 0.75 | 0–1; closeness to original |
| `voice_settings.style` | range | 0.0 | 0–1; agent-overridable as `style` |
| `voice_settings.use_speaker_boost` | boolean | true | — |
| `voice_settings.speed` | range | 1.0 | 0.7–1.2; agent-overridable as `speed` |
| `apply_text_normalization` | enum | auto | auto / on / off |
| `seed` | integer | 0 | Reproducible output (advanced) |
| `optimize_streaming_latency` | range | 0 | 0–4 (advanced) |
| `language_code` | string | — | ISO 639-1 hint (advanced) |
| `output_format` | enum | `mp3_44100_128` | Codec + bitrate; higher tiers need Creator+/Pro+ (advanced) |
---
### Edge TTS (Free)
Edge TTS uses Microsoft's neural voices via the `edge-tts` Python CLI — no API key needed.
```bash
pip install edge-tts
```
```json
{
"tts": {
"provider": "edge",
"auto": "tagged",
"edge": {
"enabled": true,
"voice": "en-US-MichelleNeural",
"rate": "+0%"
}
}
}
```
The `enabled` field must be `true` to activate the Edge provider — it has no API key to detect automatically.
Browse available voices:
```bash
edge-tts --list-voices
```
Popular voices: `en-US-MichelleNeural`, `en-GB-SoniaNeural`, `vi-VN-HoaiMyNeural`. The `rate` field adjusts speed (e.g. `+20%` faster, `-10%` slower). Output is always MP3.
#### Edge TTS Params
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `rate` | integer | 0 | Speed offset −50 to +100 (%) |
| `pitch` | integer | 0 | Pitch offset −50 to +50 (Hz) |
| `volume` | integer | 0 | Volume offset −50 to +100 (%) |
---
### MiniMax
MiniMax's T2A API supports 300+ system voices and 40+ languages. Voices are fetched dynamically — use the [Voices API](#voices-api) with `?provider=minimax`.
```json
{
"tts": {
"provider": "minimax",
"auto": "always",
"minimax": {
"api_key": "...",
"group_id": "your-group-id",
"model": "speech-02-hd",
"voice_id": "Wise_Woman"
}
}
}
```
Supported models: `speech-02-hd` (high quality), `speech-02-turbo` (faster), `speech-01-hd`, `speech-01-turbo`.
#### MiniMax Advanced Params
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `speed` | range | 1.0 | 0.5–2.0; agent-overridable as `speed` |
| `vol` | range | 1.0 | Volume 0.01–10.0 |
| `pitch` | integer | 0 | Pitch in semitones −12 to +12 |
| `emotion` | enum | — | happy/sad/angry/fearful/disgusted/surprised/neutral/excited/anxious; agent-overridable |
| `text_normalization` | boolean | — | Omitted when not set |
| `audio.format` | enum | `mp3` | mp3, pcm, flac, wav |
| `language_boost` | enum | Auto | 18 languages; improves pronunciation |
| `subtitle_enable` | boolean | — | Returns word-level timing data |
| `audio.sample_rate` | enum | Default | 8k–44.1 kHz (advanced) |
| `audio.bitrate` | enum | Default | 32–256 kbps; MP3 only (advanced) |
| `audio.channel` | enum | Default | Mono / Stereo (advanced) |
| `pronunciation_dict` | text | — | JSON array of `"word/phoneme"` rules, max 8 KB (advanced) |
Voice metadata (gender + language) is parsed automatically from MiniMax naming conventions and displayed as labels in the voice picker.
---
### Google Gemini TTS
Gemini TTS uses Google's latest preview models. An API key is required.
```json
{
"tts": {
"provider": "gemini",
"auto": "always",
"gemini": {
"api_key": "AIza...",
"model": "gemini-2.5-flash-preview-tts",
"voice": "Kore"
}
}
}
```
Supported models (all preview-stage — UI shows a **Preview** badge):
| Model | Notes |
|-------|-------|
| `gemini-2.5-flash-preview-tts` | Fast + cost-efficient |
| `gemini-2.5-pro-preview-tts` | Highest quality |
| `gemini-3.1-flash-tts-preview` | **Default** |
#### Gemini Voices (30 prebuilt)
Each voice has a style character label shown as a badge in the UI:
| Voice | Style | Voice | Style |
|-------|-------|-------|-------|
| Zephyr | Bright | Puck | Upbeat |
| Charon | Informative | Kore | Firm |
| Fenrir | Excitable | Leda | Youthful |
| Orus | Firm | Aoede | Breezy |
| Callirrhoe | Easy-going | Autonoe | Bright |
| Enceladus | Breathy | Iapetus | Clear |
| Umbriel | Easy-going | Algieba | Smooth |
| Despina | Smooth | Erinome | Clear |
| Algenib | Gravelly | Rasalgethi | Informative |
| Laomedeia | Upbeat | Achernar | Soft |
| Alnilam | Firm | Schedar | Even |
| Gacrux | Mature | Pulcherrima | Forward |
| Achird | Friendly | Zubenelgenubi | Casual |
| Vindemiatrix | Gentle | Sadachbia | Lively |
| Sadaltager | Knowledgeable | Sulafat | Warm |
#### Gemini Params
| Param | Type | Default | Group |
|-------|------|---------|-------|
| `temperature` | range | API default (1.0) | Basic — subtle effect; primary expressiveness via audio tags |
| `seed` | integer | — | Advanced |
| `presencePenalty` | range | — | Advanced — experimental |
| `frequencyPenalty` | range | — | Advanced — experimental |
#### Gemini Multi-Speaker Mode
Up to 2 speakers per request. Each speaker has a `name` and a `voice` from the 30 prebuilt voices. Configure via the portal's Voice Picker — stored as `tts.gemini.speakers` JSON blob.
#### Gemini Audio Tags
Inject expressive markers directly into the text:
```
Hello [laughs] world [sighs] how are you?
```
Categories: Emotion, Pacing, Effect, Voice quality. Full tag list is in the frontend tag picker.
#### Gemini Language Support
70+ languages — no explicit language parameter needed. Gemini detects language from input text automatically.
#### Gemini Validation Errors (422)
| Error | When |
|-------|------|
| `ErrInvalidVoice` | Voice ID not in the 30 prebuilt set |
| `ErrSpeakerLimit` | More than 2 speakers in multi-speaker mode |
| `ErrInvalidModel` | Model ID not in the allowed list |
| `MsgTtsGeminiTextOnly` | Text-only response after auto-retry (see troubleshooting) |
---
## Agent-Level Voice Override
Each agent can override TTS params via its `other_config` JSONB field without changing the system-wide config.
### Voice and Model (ElevenLabs)
| Key | Type | Description |
|-----|------|-------------|
| `tts_voice_id` | string | ElevenLabs voice ID for this agent |
| `tts_model_id` | string | ElevenLabs model ID (must be an [allowed model](#elevenlabs-model-variants)) |
### Per-Agent Params Override (v3.10.0+)
Agents can override a subset of provider params stored in `other_config.tts_params`. Only these generic keys are allowed:
| Generic key | Maps to (OpenAI) | Maps to (ElevenLabs) | Maps to (MiniMax) | Edge / Gemini |
|-------------|------------------|----------------------|-------------------|---------------|
| `speed` | `speed` | `voice_settings.speed` | `speed` | not mapped |
| `emotion` | not mapped | not mapped | `emotion` | not mapped |
| `style` | not mapped | `voice_settings.style` | not mapped | not mapped |
Keys outside this allow-list are rejected at write time. The adapter runs per-attempt inside the provider fallback loop, so each attempt uses the correct mapping for that provider.
**Resolution order:** CLI args → agent `other_config` → tenant override → provider default.
**Example:**
```json
{
"other_config": {
"tts_voice_id": "pMsXgVXv3BLzUgSXRplE",
"tts_model_id": "eleven_flash_v2_5",
"tts_params": {
"speed": 1.1,
"style": 0.3
}
}
}
```
---
## Full Config Reference
```json
{
"tts": {
"provider": "openai",
"auto": "inbound",
"mode": "final",
"max_length": 1500,
"timeout_ms": 30000,
"openai": { "api_key": "sk-...", "voice": "nova" },
"edge": { "enabled": true, "voice": "en-US-MichelleNeural" }
}
}
```
When the primary provider fails, GoClaw automatically tries the other registered providers.
### Tenant Synthesis Timeout
The synthesis deadline is controlled by the `tts.timeout_ms` key in `system_configs` (tenant admin → Config → Audio → TTS). Default is **120000 ms (120 s)**. Set a higher value for slower providers or long-form audio; the gateway enforces a per-request context deadline equal to this value.
```
tts.timeout_ms = 120000 # default; increase for slow providers
```
---
## Voices API
GoClaw exposes HTTP endpoints for discovering available TTS voices. These are tenant-scoped and require tenant admin or operator role.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/voices` | List available voices (in-memory cached, TTL 1h) |
| `GET` | `/v1/voices?provider=minimax` | List MiniMax dynamic voices |
| `POST` | `/v1/voices/refresh` | Force-invalidate the voice cache (admin only) |
### `GET /v1/voices`
Returns the voice list for the current tenant's configured provider. Results are cached in-memory per tenant with a 1-hour TTL. For ElevenLabs, voices are user-account-specific. For MiniMax, the `?provider=minimax` query parameter fetches that provider's voice list at runtime.
```json
[
{
"voice_id": "pMsXgVXv3BLzUgSXRplE",
"name": "Alice",
"labels": {
"use_case": "conversational",
"accent": "american"
}
}
]
```
A cache miss triggers an immediate fetch from the provider. Returns `500` if the provider is unreachable.
### `POST /v1/voices/refresh`
Invalidates the voice cache for the current tenant so the next `GET /v1/voices` request fetches a fresh list. Returns `202 Accepted`.
---
## Capabilities API
```
GET /v1/tts/capabilities
```
Returns the full `ProviderCapabilities` schema for all registered providers — models, static voices, param schemas, and custom feature flags. The portal uses this to render dynamic per-provider settings forms and the agent override UI.
---
## Channel Integration
### Telegram Voice Bubbles
When the originating channel is `telegram`, GoClaw automatically requests `opus` format (Ogg/Opus container) instead of MP3 — Telegram requires this for voice messages. No extra config is needed.
```mermaid
flowchart LR
REPLY["Agent reply text"] --> AUTO{"Auto mode\ncheck"}
AUTO -->|passes| STRIP["Strip markdown\n& directives"]
STRIP --> TRUNC["Truncate if >\nmax_length"]
TRUNC --> FMT{"Channel?"}
FMT -->|telegram| OPUS["Request opus"]
FMT -->|other| MP3["Request mp3"]
OPUS --> SYNTH["Synthesize"]
MP3 --> SYNTH
SYNTH --> SEND["Send as voice message"]
```
### Tagged Mode
Add `[[tts]]` anywhere in an agent reply to trigger synthesis in `tagged` mode:
```
Here's your daily briefing. [[tts]]
```
---
## Examples
**Minimal free setup with Edge TTS:**
```bash
pip install edge-tts
```
```json
{
"tts": {
"provider": "edge",
"auto": "inbound",
"edge": { "enabled": true, "voice": "en-US-JennyNeural" }
}
}
```
**OpenAI primary with ElevenLabs fallback:**
```json
{
"tts": {
"provider": "openai",
"auto": "always",
"openai": { "api_key": "sk-...", "voice": "alloy" },
"elevenlabs": { "api_key": "xi-...", "voice_id": "pMsXgVXv3BLzUgSXRplE" }
}
}
```
**Gemini multi-speaker with audio tags:**
```json
{
"tts": {
"provider": "gemini",
"auto": "always",
"gemini": {
"api_key": "AIza...",
"model": "gemini-2.5-flash-preview-tts"
}
}
}
```
Configure speakers in the portal Voice Picker — up to 2 speakers, each with a name and one of the 30 Gemini prebuilt voices.
---
## Speech-to-Text (STT)
GoClaw routes all voice/audio transcription through a unified `audio.Manager` with a provider chain. Channels (Telegram, Discord, Feishu, WhatsApp) share the same STT infrastructure.
### Unified Transcription Flow
```mermaid
flowchart TD
VOICE["Voice/audio message"] --> ROUTE{Channel type?}
ROUTE -->|Telegram / Discord / Feishu| DOWNLOAD["Download audio file"]
ROUTE -->|WhatsApp| WA_CHECK{"whatsapp_enabled\nin settings?"}
WA_CHECK -->|No| WA_FALLBACK["[Voice message]\n(default opt-out)"]
WA_CHECK -->|Yes| DOWNLOAD
DOWNLOAD --> STT_CHECK{"STT providers\nconfigured?"}
STT_CHECK -->|Yes| STT_CHAIN["Try providers in order:\nelevenlabs_scribe, proxy"]
STT_CHECK -->|No| FALLBACK["[Voice message]"]
STT_CHAIN -->|Success| TEXT["Transcribed text\n→ agent context"]
STT_CHAIN -->|Fail / 10s timeout| FALLBACK
```
### WhatsApp Opt-In
WhatsApp STT is **off by default** (`whatsapp_enabled: false`). Rationale: WhatsApp voice messages are end-to-end encrypted. Sending audio bytes to an external STT provider breaks E2E encryption. Admins must explicitly enable it in **Config → Audio → STT** and acknowledge the E2E breaking change.
When disabled (default): voice messages appear in agent context as `[Voice message]` — no audio leaves the device.
When enabled: audio is transcribed via the configured STT chain; falls back to `[Voice message]` on failure or timeout (10 s wall clock).
### STT Provider Chain
| Setting | Behavior |
|---------|----------|
| `providers: ["elevenlabs_scribe", "proxy_stt"]` | Try ElevenLabs Scribe first; fall back to legacy proxy |
| `providers: []` (empty) | Skip all STT; voice → `[Voice message]` |
| `providers` missing (nil) | Check for legacy `STTProxyURL` bridge at startup |
Configure via **Config → Audio → STT** in the web UI (stored in `builtin_tools[stt].settings.providers`). When this list is present it overrides all legacy channel-specific STT configs.
---
## STT Builtin Tool
The `stt` builtin tool (seeded by migration 050) enables agents to transcribe voice/audio input using ElevenLabs Scribe or a compatible proxy — see [Tools Overview](/tools-overview) for how to enable and configure it.
---
## Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| `tts provider not found: edge` | `enabled` not set | Add `"enabled": true` to `edge` section |
| `edge-tts failed` | CLI not installed | `pip install edge-tts` |
| `all tts providers failed` | All providers errored | Check API keys; inspect gateway logs |
| No voice in Telegram | `auto` is `off` | Set `auto: "inbound"` or `"always"` |
| Voice fires on tool results | `mode` is `all` | Set `mode: "final"` |
| MiniMax returns empty audio | Missing `group_id` | Add `group_id` from MiniMax console |
| Text cut off with `...` | Over `max_length` | Increase `max_length` in config |
| Gemini 422 `ErrInvalidVoice` | Voice not in 30 prebuilt set | Use a valid voice ID from the table above |
| Gemini 422 `ErrSpeakerLimit` | More than 2 speakers | Reduce to ≤ 2 speakers in Voice Picker |
| Gemini 422 `MsgTtsGeminiTextOnly` | Gemini returned text instead of audio after auto-retry | GoClaw retries once with an inline audio prefix; if Gemini still refuses, the error surfaces as HTTP 422. Shorten the text, remove translation/commentary, or switch model. |
| `tts_params` key rejected | Key not in allow-list | Use only `speed`, `emotion`, `style` |
---
## What's Next
- [Scheduling & Cron](/scheduling-cron) — trigger agents on a schedule
- [Extended Thinking](/extended-thinking) — deeper reasoning for complex replies
---
# Knowledge Graph
> Agents automatically extract entities and relationships from conversations, building a searchable graph of people, projects, and concepts.
## Overview
GoClaw's knowledge graph system has two parts:
1. **Extraction** — After conversations, an LLM extracts entities (people, projects, concepts) and relationships from the text
2. **Search** — Agents use the `knowledge_graph_search` tool to query the graph, traverse relationships, and discover connections
The graph is scoped per agent and per user — each agent builds its own graph from its conversations.
## Full-Text Search
Entity search uses PostgreSQL `tsvector` full-text search (migration `000031`). A stored `tsv` column is automatically generated from each entity's name and description:
```sql
tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', name || ' ' || COALESCE(description, ''))) STORED
```
A GIN index on `tsv` makes text queries fast even with large graphs. Queries like `"john"` or `"project alpha"` match partial words across name and description fields.
---
## Entity Deduplication
After extraction, GoClaw automatically checks new entities for duplicates using two signals:
1. **Embedding similarity** — HNSW KNN query finds the nearest existing entities of the same type
2. **Name similarity** — Jaro-Winkler string similarity (case-insensitive)
### Thresholds
| Scenario | Condition | Action |
|----------|-----------|--------|
| Near-certain duplicate | embedding similarity ≥ 0.98 **and** name similarity ≥ 0.85 | Auto-merged immediately |
| Possible duplicate | embedding similarity ≥ 0.90 | Flagged in `kg_dedup_candidates` for review |
**Auto-merge** keeps the entity with the higher confidence score, re-points all relations from the merged entity to the surviving one, and deletes the source entity. An advisory lock prevents concurrent merges on the same agent.
**Flagged candidates** are stored in `kg_dedup_candidates` with status `pending`. You can list, dismiss, or manually merge them via the API.
### Dedup Management Workflow
**1. Scan for duplicates** — Run a full scan across all entities:
```bash
POST /v1/agents/{agentID}/kg/dedup/scan
Content-Type: application/json
{"threshold": 0.90, "limit": 100}
```
Useful after bulk imports or initial onboarding. Results are added to the review queue.
**2. Review candidates:**
```bash
GET /v1/agents/{agentID}/kg/dedup?user_id=xxx
```
Returns `DedupCandidate[]` with fields: `entity_a`, `entity_b`, `similarity`, `status`.
**3. Merge:**
```bash
POST /v1/agents/{agentID}/kg/merge
Content-Type: application/json
{"target_id": "john-doe-uuid", "source_id": "j-doe-uuid"}
```
Re-points all relations from `source_id` to `target_id`, then deletes the source entity.
**4. Dismiss:**
```bash
POST /v1/agents/{agentID}/kg/dedup/dismiss
Content-Type: application/json
{"candidate_id": "candidate-uuid"}
```
Marks the pair as not-duplicate — it won't appear in future review queues.
---
## Searching the Graph
**Tool:** `knowledge_graph_search`
| Parameter | Type | Description |
|-----------|------|-------------|
| `query` | string | Entity name, keyword, or `*` to list all (required) |
| `entity_type` | string | Filter: `person`, `organization`, `project`, `product`, `technology`, `task`, `event`, `document`, `concept`, `location` |
| `entity_id` | string | Start point for relationship traversal |
| `max_depth` | int | Traversal depth (default 2, max 3) |
### 3-Tier Search Fallback
The tool uses a 3-tier fallback strategy to ensure results are always returned:
1. **Traversal** (when `entity_id` provided) — Bidirectional multi-hop traversal up to `max_depth`, returns up to 20 results with path info and relation types
2. **Direct connections** (fallback if traversal returns nothing) — Bidirectional 1-hop relations, capped at 10
3. **Text search** (fallback if no connections) — Full-text search on entity names/descriptions, returns up to 10 results with their relations (5 per entity)
When all three tiers return nothing, the tool returns the top 10 existing entities as hints so the model knows what's available in the graph.
### Search modes
**Text search** — Find entities by name or keyword:
```
query: "John"
```
**List all** — Show all entities (up to 30):
```
query: "*"
```
**Traverse relationships** — Start from an entity and follow connections in both directions:
```
query: "*"
entity_id: "project-alpha"
max_depth: 2
```
Results include entity names, types, descriptions, depth, traversal path, and the relation type used to reach each entity.
---
## REST API Reference
All endpoints require authentication (`Authorization: Bearer `). Add `?user_id=` to scope results to a specific user.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{agentID}/kg/entities` | List or search entities |
| `GET` | `/v1/agents/{agentID}/kg/entities/{entityID}` | Get entity with its relations |
| `POST` | `/v1/agents/{agentID}/kg/entities` | Upsert entity |
| `DELETE` | `/v1/agents/{agentID}/kg/entities/{entityID}` | Delete entity (cascades relations) |
| `POST` | `/v1/agents/{agentID}/kg/traverse` | Traverse the graph from an entity |
| `POST` | `/v1/agents/{agentID}/kg/extract` | LLM-powered extraction from text |
| `GET` | `/v1/agents/{agentID}/kg/stats` | Graph statistics |
| `GET` | `/v1/agents/{agentID}/kg/graph` | Full graph for visualization |
| `POST` | `/v1/agents/{agentID}/kg/dedup/scan` | Scan for duplicate candidates |
| `GET` | `/v1/agents/{agentID}/kg/dedup` | List dedup candidates |
| `POST` | `/v1/agents/{agentID}/kg/merge` | Merge two entities |
| `POST` | `/v1/agents/{agentID}/kg/dedup/dismiss` | Dismiss a dedup candidate |
---
## Data Model
### Entity
```json
{
"id": "uuid",
"agent_id": "agent-uuid",
"user_id": "optional-user-id",
"external_id": "john-doe",
"name": "John Doe",
"entity_type": "person",
"description": "Backend engineer on the platform team",
"properties": {"team": "platform"},
"source_id": "optional-source-ref",
"confidence": 0.95,
"created_at": 1711900000,
"updated_at": 1711900000
}
```
| Field | Description |
|-------|-------------|
| `external_id` | Human-readable slug (e.g., `john-doe`). Used for upsert dedup. |
| `properties` | Arbitrary key-value metadata from extraction |
| `source_id` | Optional reference to the source conversation or document |
| `confidence` | Extraction confidence (0.0–1.0); surviving entity in merges keeps the higher value |
### Relation
```json
{
"id": "uuid",
"agent_id": "agent-uuid",
"user_id": "optional-user-id",
"source_entity_id": "john-doe-uuid",
"relation_type": "works_on",
"target_entity_id": "project-alpha-uuid",
"confidence": 0.9,
"properties": {},
"created_at": 1711900000
}
```
Relations are directional: `source --relation_type--> target`. Deleting an entity cascades and removes all its relations.
---
## Entity Types
| Type | Examples |
|------|----------|
| `person` | Team members, contacts, stakeholders |
| `organization` | Companies, teams, departments |
| `project` | Initiatives, codebases, programs |
| `product` | Software products, services, features |
| `technology` | Languages, frameworks, platforms |
| `task` | Action items, tickets, assignments |
| `event` | Meetings, deadlines, milestones |
| `document` | Reports, specs, wikis, runbooks |
| `concept` | Methodologies, ideas, principles |
| `location` | Offices, cities, regions |
---
## Graph Statistics & Visualization
### Statistics
```bash
GET /v1/agents/{agentID}/kg/stats?user_id=xxx
```
```json
{
"entity_count": 42,
"relation_count": 87,
"entity_types": {
"person": 15,
"project": 8,
"concept": 12,
"task": 7
}
}
```
### Full Graph for Visualization
```bash
GET /v1/agents/{agentID}/kg/graph?user_id=xxx&limit=200
```
Returns all entities and relations suitable for rendering in a graph UI. Default limit is 200 entities; relations are capped at 3× the entity limit.
The web dashboard renders the graph using **ReactFlow** with **D3 Force Simulation** (`d3-force`) for automatic node positioning:
- **Force layout** — `forceSimulation` computes node positions using link distance, charge repulsion (`forceManyBody`), centering (`forceCenter`), and collision avoidance (`forceCollide`). Forces scale by node count (tighter for small graphs, spread for large).
- **Node sizing by type** — Each entity type has a different mass (organization=8, project=6, person=4, etc.), so hub entities naturally sit at the center.
- **Degree centrality** — When entities exceed the display limit (50), the graph keeps the most-connected hub nodes. Nodes with ≥4 connections get a glow highlight.
- **Interactive selection** — Clicking a node highlights its connected edges with labels, dims unrelated edges, and opens the entity detail dialog.
- **Theme support** — Dual-theme color palette (dark/light) with per-entity-type colors. Theme changes update colors without re-running the layout.
- **Performance** — Node components are `memo`-ized, layout runs in `setTimeout(0)` to avoid blocking, and edge updates use `useTransition` for responsive interaction.
---
## Shared Knowledge Graph
By default, the knowledge graph is scoped per agent **and** per user — each user builds their own graph. When `share_knowledge_graph` is enabled in the agent's workspace sharing config, the graph becomes agent-level (shared across all users):
```yaml
workspace_sharing:
share_knowledge_graph: true
```
In shared mode, `user_id` is ignored for all KG operations — entities and relations from all users are stored and queried together. This is useful for team agents where everyone should see the same entity graph.
> **Note:** `share_knowledge_graph` is independent of `share_memory`. You can share memory without sharing the graph, or vice versa.
---
## Automatic Extraction on Memory Write
When an agent writes to its memory files (e.g., `MEMORY.md` or files under `memory/`), GoClaw automatically triggers KG extraction on the written content. This happens via the `MemoryInterceptor`, which calls the configured LLM to extract entities and relations from the new memory text.
This means agents continuously build their knowledge graph as they learn — no manual `/kg/extract` calls needed for normal conversations. The extract API is available for bulk imports or external integrations.
---
## Confidence Pruning
Remove low-confidence entities and relations in bulk using `PruneByConfidence`:
```bash
# Internal service call — prunes items below threshold
# Returns count of pruned entities and relations
PruneByConfidence(agentID, userID, minConfidence)
```
This is useful after bulk imports where many low-confidence items accumulate. Items with `confidence < minConfidence` are deleted; their relations cascade automatically.
---
## Example
After several conversations about a project, an agent's knowledge graph might contain:
```
Entities:
[person] Alice — Backend lead
[person] Bob — Frontend developer
[project] Project Alpha — E-commerce platform
[concept] GraphQL — API layer technology
Relations:
Alice --manages--> Project Alpha
Bob --works_on--> Project Alpha
Project Alpha --uses--> GraphQL
```
An agent can then answer questions like *"Who is working on Project Alpha?"* by traversing the graph.
---
## Knowledge Graph vs Knowledge Vault
The Knowledge Graph and [Knowledge Vault](knowledge-vault.md) are complementary systems:
| | Knowledge Graph | Knowledge Vault |
|--|----------------|-----------------|
| **What it stores** | Extracted entities and typed relations | Full documents (notes, specs, context files) |
| **How it's built** | Automatic LLM extraction from conversations | Agent writes files; VaultSyncWorker registers them |
| **Search** | Entity name / relationship traversal | Hybrid FTS + vector on title, path, content |
| **Links** | Typed relation edges (`works_on`, `manages`, …) | Wikilinks `[[target]]` and explicit references |
| **Scope** | Per-agent, optionally shared across team | personal / team / shared scope per document |
When an agent uses `vault_search`, the VaultSearchService fans out to **both** the vault and the knowledge graph simultaneously, merging results with weighted scoring.
---
## What's Next
- [Knowledge Vault](knowledge-vault.md) — Document-level knowledge store with wikilinks and semantic search
- [Memory System](../core-concepts/memory-system.md) — Vector-based long-term memory
- [Sessions & History](../core-concepts/sessions-and-history.md) — Conversation storage
---
# Knowledge Vault
> A structured knowledge store that lets agents curate workspace documents with bidirectional wikilinks, semantic search, and team-scoped access — all layered on top of existing memory systems.
Knowledge Vault is a **v3-only** feature. It sits between agents and the episodic/KG stores, adding document-level notes with explicit relationships.
> **Vault vs Knowledge Graph** — Vault stores full documents (notes, context files, specs) with lexical + semantic search and wikilinks. The [Knowledge Graph](knowledge-graph.md) stores extracted *entities and relations* from conversations. They complement each other: vault for curated docs, KG for auto-extracted facts. The VaultSearchService fans out to both simultaneously.
## Data Model
### vault_documents
Registry of document metadata. Content lives on the filesystem; the registry stores path, hash, embeddings, and links.
| Column | Type | Notes |
|--------|------|-------|
| `id` | UUID | Primary key |
| `tenant_id` | UUID | Multi-tenant isolation |
| `agent_id` | UUID | Per-agent namespace; **nullable** for team-scoped or tenant-shared files (migration 046) |
| `scope` | TEXT | `personal` \| `team` \| `shared` |
| `chat_id` | TEXT | Chat-scope isolation for isolated teams; NULL = no chat scope (team-wide or legacy) |
| `path` | TEXT | Workspace-relative path (e.g., `workspace/notes/foo.md`) |
| `title` | TEXT | Display name |
| `doc_type` | TEXT | `context`, `memory`, `note`, `skill`, `episodic`, `image`, `video`, `audio`, `document` |
| `content_hash` | TEXT | SHA-256 of file content (change detection) |
| `embedding` | vector(1536) | pgvector semantic similarity |
| `tsv` | tsvector | GIN FTS index on title + path + summary |
| `metadata` | JSONB | Optional custom fields |
### Chat-Scope Isolation
Migration `000056` adds the `chat_id` column to `vault_documents` to support isolated teams — groups where each chat channel is fully partitioned.
**Invariant for isolated teams:**
- `chat_id != NULL` → document is visible only to that chat
- `chat_id IS NULL` → document is team-wide (shared or legacy)
- Both rescan and search enforce this filter: `chat_id = OR chat_id IS NULL`
**What migration `000056` does:**
1. Adds column `vault_documents.chat_id TEXT` (nullable)
2. Adds composite index `idx_vault_docs_team_chat` on `(team_id, chat_id) WHERE team_id IS NOT NULL`
3. Drops the `vault_documents_scope_consistency` constraint before running backfill UPDATEs — the constraint was added as `NOT VALID` in migration 055, meaning it skipped existing rows but still re-checked every UPDATE. Legacy data (pre-M46/M43) often violated the invariant, causing the backfill to abort and leaving migration 056 in a dirty state (issue #1035, fixed in v3.11.2). The constraint is re-added at the end of the migration with `NOT VALID`.
**Backfill logic:**
Migration 056 backfills `chat_id` for two groups:
- **Team-scoped docs** (`scope='team'`): extracts the chat segment from the path (`teams///...` or `tenants//teams///...`). Segments starting with `.` (config dirs such as `.goclaw`) are skipped.
- **Legacy docs** (`team_id IS NULL`): a broader regex covers **all channel integrations**: `telegram`, `discord`, `zalo`, `feishu`, `lark`, `whatsapp`, `slack`, `line`, `messenger`, `wechat`, `viber`, `ws`, `delegate`, `api` — not just telegram/discord as in older releases.
**Related search parameters:**
| Parameter | Type | Notes |
|-----------|------|-------|
| `ChatID` | *string | Pointer to the chat ID to filter by; nil = no filter |
| `TeamIsolated` | bool | true = apply ChatID filter; false = skip (shared/personal) |
### vault_links
Bidirectional links between documents (wikilinks, explicit references, and enrichment-generated semantic links).
| Column | Type | Notes |
|--------|------|-------|
| `from_doc_id` | UUID | Source document |
| `to_doc_id` | UUID | Target document |
| `link_type` | TEXT | `wikilink`, `reference`, `depends_on`, `extends`, `related`, `supersedes`, `contradicts`, `task_attachment`, `delegation_attachment` |
| `context` | TEXT | ~50-char surrounding text snippet |
| `metadata` | JSONB | Extra metadata from enrichment pipeline (migration 048) |
Unique constraint: `(from_doc_id, to_doc_id, link_type)` — no duplicate links.
### vault_versions
Version history prepared for v3.1 — table exists but is empty in v3.0.
---
## Wikilinks
Agents can create bidirectional markdown links in `[[target]]` format.
### Syntax
```markdown
See [[architecture/components]] for details.
Reference [[SOUL.md|agent persona]] here.
Link [[../parent-project]] up.
```
- `[[path/to/file.md]]` — path-based target
- `[[name|display text]]` — display text is cosmetic only
- `.md` extension auto-appended if missing
- Empty or whitespace-only targets are skipped
### Resolution Strategy
When resolving a wikilink target:
1. **Exact path match** — find document by path
2. **With .md suffix** — retry if target lacks extension
3. **Basename search** — scan all agent docs, match by filename (case-insensitive)
4. **Unresolved** — silently skipped; backlinks can be incomplete
### Link Sync
`SyncDocLinks` keeps `vault_links` in sync with document content:
1. Extract all `[[...]]` patterns from content
2. Delete existing outgoing links for the document (replace strategy)
3. Resolve each target and create `vault_link` rows for resolved targets
This runs on every document upsert and on each VaultSyncWorker file event.
---
## Search
### Vault Search (Single Store)
Hybrid FTS + vector search on a single vault:
- **FTS**: PostgreSQL `plainto_tsquery()` on `tsv` (title + path keywords)
- **Vector**: pgvector cosine similarity on embeddings (semantic)
- **Scoring**: Scores from each method normalized to 0–1, then combined with query-time weights
### Unified Search (Cross-Store)
`VaultSearchService` fans out in parallel across all knowledge sources:
| Source | Weight | What it searches |
|--------|--------|-----------------|
| Vault | 0.4 | Document titles, paths, embeddings |
| Episodic | 0.3 | Session summaries |
| Knowledge Graph | 0.3 | Entity names and descriptions |
Results are normalized per source (max score = 1.0), weighted, merged, deduplicated by ID, and sorted by final score descending.
### Search Parameters
| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `Query` | string | — | Required: natural language |
| `AgentID` | string | — | Scope to agent |
| `TenantID` | string | — | Scope to tenant |
| `Scope` | string | all | `personal`, `team`, `shared` |
| `DocTypes` | []string | all | `context`, `memory`, `note`, `skill`, `episodic` |
| `MaxResults` | int | 10 | Final result set size |
| `MinScore` | float64 | 0.0 | Minimum score filter |
---
## Filesystem Sync
`VaultSyncWorker` watches workspace directories for changes using `fsnotify`:
1. **Debounce**: 500ms — multiple rapid changes collapse to one batch
2. For each changed file:
- Compute SHA-256 hash
- Compare to `vault_documents.content_hash`
- If different: update hash in DB
- If file deleted: mark `metadata["deleted"] = true`
**Note:** Sync is one-way — only registered documents are watched. New files must first be registered by an agent write. The vault does not write back to the filesystem.
---
## Enrichment Pipeline
After each document upsert, **EnrichWorker** processes the event asynchronously to enrich vault documents with summaries, embeddings, and semantic links.
### What EnrichWorker does
1. Generates a text summary of the document content
2. Computes a vector embedding for semantic search
3. Classifies semantic relationships to other documents in the vault and creates `vault_link` rows
### Semantic link types
The classifier produces links with one of six relationship types:
| Type | Meaning |
|------|---------|
| `reference` | Document cites another as a source |
| `depends_on` | Document requires another to be meaningful |
| `extends` | Document adds to or builds upon another |
| `related` | General topical relationship |
| `supersedes` | Document replaces or obsoletes another |
| `contradicts` | Document conflicts with another |
### Special attachment link types
Two additional link types are created by the task/delegation system rather than the classifier:
- `task_attachment` — links a vault document to a team task it was attached to
- `delegation_attachment` — links a vault document to a delegation it was attached to
These are not affected by enrichment cleanup or rescan.
### Enrichment progress
Real-time enrichment progress is broadcast as WebSocket events. The UI shows per-document status while the worker runs.
### Stop and rescan controls
From the UI (or REST API), users can:
- **Stop enrichment** — halts the EnrichWorker for the current tenant
- **Trigger rescan** — re-queues all vault documents for re-enrichment (useful after model or config changes)
---
## Media Document Support
The vault accepts binary and media files in addition to text documents. Supported file types are controlled by an extension whitelist.
### doc_type values for media files
| `doc_type` | Used for |
|-----------|---------|
| `image` | PNG, JPG, GIF, WEBP, SVG, etc. |
| `video` | MP4, MOV, AVI, etc. |
| `audio` | MP3, WAV, OGG, etc. |
| `document` | PDF, DOCX, XLSX, etc. |
### Synthetic summaries for media
Because media files cannot be read as text, the vault uses `SynthesizeMediaSummary()` to generate a deterministic semantic summary from the filename and parent folder context. No LLM call is needed. The summary is stored in `vault_documents.summary` and included in the FTS index, enabling keyword discovery of media files by name and location.
---
## Agent Tools
### vault_search
Primary discovery tool. Searches across vault, episodic memory, and Knowledge Graph with unified ranking.
```json
{
"query": "authentication flow",
"scope": "team",
"types": "context,note",
"maxResults": 10
}
```
Each result carries a **source-specific ID field** that tells you which follow-up tool to use:
| Source | ID field | Follow-up tool |
|--------|----------|---------------|
| `vault` | `doc_id` | `vault_read(doc_id=...)` |
| `kg` | `entity_id` | `knowledge_graph_search(entity_id=...)` |
| `episodic` | `episodic_id` | `memory_expand(id=episodic_id)` |
> **ID namespace protection:** If you pass a `entity_id` or `episodic_id` to `vault_read` by mistake, the tool returns a descriptive error telling you the correct tool to use — rather than a generic "document not found". Always use the `doc_id` from vault results with `vault_read`.
> **Note on linking:** Explicit document linking is now handled automatically by the enrichment pipeline. The `vault_link` agent tool has been removed. Links are created via wikilink syntax in document content (`[[target]]`) or generated semantically by EnrichWorker. You can view links via `GET /v1/agents/{agentID}/vault/documents/{docID}/links`.
---
## REST API
All endpoints require `Authorization: Bearer `.
### Per-Agent Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{agentID}/vault/documents` | List documents (scope, doc_type, limit, offset) |
| `GET` | `/v1/agents/{agentID}/vault/documents/{docID}` | Get single document |
| `POST` | `/v1/agents/{agentID}/vault/documents` | Create document (optional `content` body — see below) |
| `PUT` | `/v1/agents/{agentID}/vault/documents/{docID}` | Update document (optional `content` body — see below) |
| `POST` | `/v1/agents/{agentID}/vault/search` | Unified search |
| `GET` | `/v1/agents/{agentID}/vault/documents/{docID}/links` | Outlinks + backlinks |
### Cross-Agent Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/vault/documents` | List across all tenant agents (filter by `agent_id`) |
| `POST` | `/v1/vault/documents` | Create document (optional `content` body — see below) |
| `PUT` | `/v1/vault/documents/{docID}` | Update document (optional `content` body — see below) |
| `GET` | `/v1/vault/tree` | Tree view of vault structure |
| `GET` | `/v1/vault/graph` | Cross-tenant graph visualization (node limit: 2000, FA2 layout) |
### Writing Content on Create/Update
`POST`/`PUT` accept an optional `content` field. When supplied, the bytes are materialised at `/`, the SHA-256 hash is stored on the row, and an enrichment event is emitted so summaries, embeddings, and links are computed — the same code path the multipart `/v1/vault/upload` endpoint uses.
| `content` value | Behaviour |
|-----------------|-----------|
| field omitted | Metadata-only stub; no file written, no enrichment event |
| present and non-empty | Bytes written to disk; hash stored; enrichment fires |
| present and empty (`""`) | A 0-byte file is written; enrichment still fires |
The path must use an allowed extension (same whitelist as `/v1/vault/upload`). Writes are sandboxed inside the tenant workspace: lexical `../` checks, symlink-ancestor resolution, and an atomic `O_NOFOLLOW` open reject any attempt to follow a symlink out of the workspace.
```bash
POST /v1/vault/documents
Content-Type: application/json
Authorization: Bearer
{
"path": "notes/auth.md",
"title": "Authentication Flow",
"doc_type": "note",
"content": "# Authentication Flow\n\nSee [[architecture/components]] for details."
}
```
### Enrichment Control Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/vault/enrichment/stop` | Stop the enrichment worker |
### Example: Unified Search
```bash
POST /v1/agents/agent-123/vault/search
Content-Type: application/json
Authorization: Bearer
{
"query": "authentication flow",
"scope": "personal",
"max_results": 5
}
```
```json
[
{
"document": {
"id": "doc-456",
"path": "notes/auth.md",
"title": "Authentication Flow",
"doc_type": "note"
},
"score": 0.92,
"source": "vault"
},
{
"document": {"id": "episodic-789", "title": "Session-2026-04-06"},
"score": 0.68,
"source": "episodic"
}
]
```
### Example: Get Links
```bash
GET /v1/agents/agent-123/vault/documents/doc-456/links
```
```json
{
"outlinks": [
{
"id": "uuid",
"to_doc_id": "uuid",
"link_type": "wikilink",
"context": "See [[target]] for details."
}
],
"backlinks": [
{
"id": "uuid",
"from_doc_id": "uuid",
"link_type": "wikilink",
"context": "Reference [[auth.md]] here."
}
]
}
```
---
## Recent Migrations
| Migration | Name | What changed |
|-----------|------|--------------|
| 046 | `vault_nullable_agent_id` | Makes `vault_documents.agent_id` nullable for team-scoped and tenant-shared files |
| 048 | `vault_media_linking` | Adds `base_name` generated column on `team_task_attachments`; adds `metadata JSONB` on `vault_links`; fixes CASCADE FK constraints |
| 049 | `vault_path_prefix_index` | Adds concurrent index `idx_vault_docs_path_prefix` with `text_pattern_ops` for fast prefix queries |
| 056 | `vault_chat_id` | Adds `chat_id` column + `idx_vault_docs_team_chat` index; backfills legacy data from all channel integrations; drops and re-adds scope-consistency CHECK (v3.11.1 + fix v3.11.2) |
---
## Requirements
- **PostgreSQL** with `pgvector` extension (embeddings)
- **Migration** `000038_vault_tables` must have run successfully
- **VaultStore** initialized during gateway startup
- **VaultSyncWorker** started for filesystem sync
- **EnrichWorker** started for automatic enrichment (summaries, embeddings, semantic links)
No feature flag. Vault is active if the migration ran and VaultStore initialized.
---
## Limitations
- Vault documents are **not auto-injected** into the agent system prompt — they must be retrieved via `vault_search`
- FTS indexes title + path only; content requires vector embeddings for discovery
- Sync is **one-way** (filesystem → vault; vault does not write back)
- **No conflict resolution** — concurrent edits use last-write-wins
- **Version history** (`vault_versions` table) prepared for v3.1; empty in v3.0
---
## What's Next
- [Knowledge Graph](knowledge-graph.md) — Entity and relation graph auto-extracted from conversations
- [Memory System](../core-concepts/memory-system.md) — Vector-based long-term memory
- [Context Files](../agents/context-files.md) — Static documents injected into agent context
---
# Caching
> Reduce database queries with in-memory or Redis caching for frequently accessed data.
## Overview
GoClaw uses a generic caching layer to reduce repeated database queries. Three cache instances are created at startup:
| Cache instance | Key prefix | What it stores |
|----------------|------------|----------------|
| `ctx:agent` | Agent-level context files | `SOUL.md`, `IDENTITY.md`, etc. per agent |
| `ctx:user` | User-level context files | Per-user context files keyed by `agentID:userID` |
| `grp:writers` | Group file writer lists | Writer permission lists keyed by `agentID:groupID` |
All three instances share the same TTL: **5 minutes**.
Two backends are available:
| Backend | When to use |
|---------|-------------|
| **In-memory** (default) | Single instance, development, small deployments |
| **Redis** | Multi-instance production, shared cache across replicas |
Both backends are **fail-open** — cache errors are logged as warnings but never block operations. A cache miss simply means the operation proceeds with a fresh database query.
## Redis Cache
Enable Redis caching by building GoClaw with the `redis` build tag and setting `GOCLAW_REDIS_DSN`.
```bash
go build -tags redis ./...
export GOCLAW_REDIS_DSN="redis://localhost:6379/0"
```
If `GOCLAW_REDIS_DSN` is unset or the connection fails at startup, GoClaw falls back to in-memory cache automatically.
**Key format:** `goclaw:{prefix}:{key}`
For example, an agent context file entry is stored as `goclaw:ctx:agent:`.
**Connection settings:**
- Pool size: 10 connections
- Min idle: 2 connections
- Dial timeout: 5s
- Read timeout: 3s
- Write timeout: 3s
- Health check: PING on startup
**DSN format:**
```
redis://localhost:6379/0
redis://:password@redis.example.com:6379/1
```
Values are serialized as JSON. Pattern deletion uses SCAN with batch size of 100 keys per iteration.
---
## Permission Cache
GoClaw includes a dedicated `PermissionCache` for hot permission lookups that happen on every request. Unlike the context file caches, the permission cache is always in-memory — it does not use Redis.
| Cache | TTL | Key format | What it caches |
|---|---|---|---|
| `tenantRole` | 30s | `tenantID:userID` | User's role within a tenant |
| `agentAccess` | 30s | `agentID:userID` | Whether user can access an agent + their role |
| `teamAccess` | 30s | `teamID:userID` | Whether user can access a team |
**Invalidation via pubsub**: When a user's permissions change (e.g., role update, agent access revoked), GoClaw publishes a `CacheInvalidate` event on the internal bus. The permission cache processes these events:
- `CacheKindTenantUsers` — clears all tenant role entries (short TTL makes a full clear acceptable)
- `CacheKindAgentAccess` — removes all entries for that `agentID` prefix
- `CacheKindTeamAccess` — removes all entries for that `teamID` prefix
Permission changes take effect within 30 seconds at most, with immediate invalidation on write paths.
---
## Cache Behavior
Both backends implement the same interface:
| Operation | Behavior |
|-----------|----------|
| `Get` | Returns value + found flag; for in-memory, deletes expired entries on read |
| `Set` | Stores value with TTL; TTL of `0` means the entry never expires |
| `Delete` | Removes single key |
| `DeleteByPrefix` | Removes all keys matching a prefix (in-memory: range scan; Redis: SCAN + DEL) |
| `Clear` | Removes all entries under the cache instance's key prefix |
**Error handling:** All Redis errors are treated as cache misses. Connection failures, serialization errors, and timeouts are logged but never propagated to callers.
---
## What's Next
- [Database Setup](/deploy-database) — PostgreSQL configuration
- [Production Checklist](/deploy-checklist) — Deploy with confidence
---
# Browser Automation
> Give your agents a real browser — navigate pages, take screenshots, scrape content, and fill forms.
## Overview
GoClaw includes a built-in browser automation tool powered by [Rod](https://github.com/go-rod/rod) and the Chrome DevTools Protocol (CDP). Agents can open URLs, interact with elements, capture screenshots, and read page content — all through a structured tool interface.
Two operating modes are supported:
- **Local Chrome**: Rod launches a local Chrome process automatically
- **Remote Chrome sidecar**: Connect to a headless Chrome container via CDP (recommended for servers and Docker)
## Local Chrome (Dev Only)
Without `GOCLAW_BROWSER_REMOTE_URL`, Rod launches a local Chrome process. Chrome must be installed on the host. This is suitable for local development but not recommended for servers.
---
## How the Browser Tool Works
Agents interact with the browser via a single `browser` tool with an `action` parameter:
```mermaid
flowchart LR
AGENT["Agent"] --> TOOL["browser tool"]
TOOL --> START["start"]
TOOL --> OPEN["open URL"]
TOOL --> SNAP["snapshot\n(get refs)"]
TOOL --> ACT["act\n(click/type/press)"]
TOOL --> SHOT["screenshot"]
SNAP --> REFS["Element refs\ne1, e2, e3..."]
REFS --> ACT
```
The standard workflow is:
1. `start` — launch or connect to browser (auto-triggered by most actions)
2. `open` — open a URL in a new tab, get `targetId`
3. `snapshot` — get the page accessibility tree with element refs (`e1`, `e2`, ...)
4. `act` — interact with elements using refs
5. `snapshot` again to verify changes
---
## Available Actions
| Action | Description | Required params |
|--------|-------------|----------------|
| `status` | Browser running state and tab count | — |
| `start` | Launch or connect browser | — |
| `stop` | Close local browser or disconnect from remote sidecar (sidecar container keeps running) | — |
| `tabs` | List open tabs with URLs | — |
| `open` | Open URL in new tab | `targetUrl` |
| `close` | Close a tab | `targetId` |
| `snapshot` | Get accessibility tree with element refs | `targetId` (optional) |
| `screenshot` | Capture PNG screenshot | `targetId`, `fullPage` |
| `navigate` | Navigate existing tab to URL | `targetId`, `targetUrl` |
| `console` | Get browser console messages (buffer is cleared after each call) | `targetId` |
| `act` | Interact with an element | `request` object |
### Act Request Kinds
| Kind | What it does | Required fields | Optional fields |
|------|-------------|----------------|----------------|
| `click` | Click an element | `ref` | `doubleClick` (bool), `button` (`"left"`, `"right"`, `"middle"`) |
| `type` | Type text into an element | `ref`, `text` | `submit` (bool — press Enter after), `slowly` (bool — character-by-character) |
| `press` | Press a keyboard key | `key` (e.g. `"Enter"`, `"Tab"`, `"Escape"`) | — |
| `hover` | Hover over an element | `ref` | — |
| `wait` | Wait for condition | one of: `timeMs`, `text`, `textGone`, `url`, or `fn` | — |
| `evaluate` | Run JavaScript and return result | `fn` | — |
---
## Use Cases
### Screenshot a Page
```json
{ "action": "open", "targetUrl": "https://example.com" }
```
```json
{ "action": "screenshot", "targetId": "", "fullPage": true }
```
The screenshot is saved to a temp file and returned as `MEDIA:/tmp/goclaw_screenshot_*.png` — the media pipeline delivers it as an image (e.g. Telegram photo).
### Scrape Page Content
```json
{ "action": "open", "targetUrl": "https://example.com" }
```
```json
{ "action": "snapshot", "targetId": "", "compact": true, "maxChars": 8000 }
```
The snapshot returns an accessibility tree. Use `interactive: true` to see only clickable/typeable elements. Use `depth` to limit tree depth.
### Fill and Submit a Form
```json
{ "action": "open", "targetUrl": "https://example.com/login" }
```
```json
{ "action": "snapshot", "targetId": "" }
```
```json
{
"action": "act",
"targetId": "",
"request": { "kind": "type", "ref": "e3", "text": "user@example.com" }
}
```
```json
{
"action": "act",
"targetId": "",
"request": { "kind": "type", "ref": "e4", "text": "mypassword", "submit": true }
}
```
`submit: true` presses Enter after typing.
### Run JavaScript
```json
{
"action": "act",
"targetId": "",
"request": { "kind": "evaluate", "fn": "document.title" }
}
```
---
## Snapshot Options
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `maxChars` | number | 8000 | Max characters in snapshot output |
| `interactive` | boolean | false | Show only interactive elements |
| `compact` | boolean | false | Remove empty structural nodes |
| `depth` | number | unlimited | Max tree depth |
---
## Selected Cookie Sync
Server-side browser sessions start with no login state. **Selected cookie sync** lets a user pick specific cookies from a site they are logged into and copy them into GoClaw, so an agent's browser can act as that signed-in session — without sharing a password.
A small Chrome extension (`chrome-selected-cookie-sync`) does the picking. There is **no automatic background sync**: the user opens the extension on the active tab, checks the exact cookies to share, and clicks **Sync**. GoClaw stores the values encrypted and replays them into the agent's browser only for matching domains and paths.
```mermaid
flowchart LR
USER["User on logged-in site"] --> EXT["chrome-selected-cookie-sync\nextension"]
EXT -->|"POST /v1/browser/cookies/sync"| GW["GoClaw gateway"]
GW -->|"AES-256-GCM encrypt"| DB[("browser_cookies\ntable")]
DB -->|"decrypt + domain/path match"| AGENT["Agent browser session"]
```
### Endpoints
All three endpoints require **operator** auth (gateway token, API key, or paired-browser auth).
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v1/browser/cookies/sync` | Upsert selected cookies for an agent |
| `GET` | `/v1/browser/cookies?agent_id=&domain=&name=&path=` | List synced cookie **metadata** (never values) |
| `DELETE` | `/v1/browser/cookies?agent_id=&domain=&name=&path=` | Revoke synced cookies |
The client only ever chooses `agent_id`. **Tenant and user are derived from the auth context**, not from the request body — a client cannot spoof another user's cookies. Sync is rejected when the auth context has no user, or when no `agent_id` is supplied.
**Sync request body:**
```json
{
"agent_id": "default",
"source": "chrome-selected-cookie-sync",
"cookies": [
{
"domain": "example.com",
"name": "session",
"path": "/",
"value": "REDACTED",
"secure": true,
"httpOnly": true,
"sameSite": "lax",
"expirationDate": 1789999999
}
]
}
```
**Response:** `{ "synced": 1 }`. Limits: max 200 cookies per request, 16 KB per cookie value, 1 MB total body.
The `GET` response returns metadata only — `domain`, `name`, `path`, `secure`, `httpOnly`, `sameSite`, `expiresAt`, `source`, `updatedAt`. Cookie **values are never returned**.
### Scope and uniqueness
Each stored cookie is keyed by `(tenant_id, user_id, agent_id, domain, path, name)`. Re-syncing the same cookie updates the existing row (upsert). This scope is what keeps one user's cookies from leaking into another user's or another agent's browser session.
### Security
- **Encrypted at rest**: cookie values are encrypted with AES-256-GCM before being written to the `browser_cookies` table. Requires the `GOCLAW_ENCRYPTION_KEY` environment variable — **sync and list fail closed (HTTP 503) when it is unset**, so cookies are never persisted in plaintext.
- **Write-only values**: the list endpoint and audit logs return metadata only. Cookie values never appear in API responses or logs.
- **Scoped replay**: the agent browser receives a cookie only when the requested URL's host and path match the stored cookie's domain/path, the cookie has not expired, and the tenant/user/agent scope matches.
- **Explicit selection**: the extension reads cookies only after the user grants host permission for the active site, and sends only the cookies the user checked.
- **Revocation**: delete from the extension or call `DELETE /v1/browser/cookies?agent_id=&domain=` to remove synced cookies. Omitting `domain` removes all cookies for that agent.
### How the agent consumes synced cookies
When the agent's browser navigates to an `http(s)` URL, GoClaw's cookie provider looks up cookies for the current browser scope (`tenant_id` / `user_id` / `agent_id`), decrypts them, and injects only those whose domain and path match the target URL (and that have not expired). Non-HTTP schemes get no cookies. The agent never sees raw values — they are applied directly to the Chrome session via CDP.
### Install the extension
The extension lives in the GoClaw repo at `extensions/chrome-selected-cookie-sync/`.
1. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select the `extensions/chrome-selected-cookie-sync/` folder.
2. Open a tab on the site you are logged into, then click the extension icon.
3. Fill in the popup:
- **Gateway URL** — e.g. `http://localhost:18790`
- **Token** — an operator token (sent as `Authorization: Bearer `)
- **User ID** — sent as the `X-GoClaw-User-Id` header
- **Agent ID** — e.g. `default`
4. Click **Grant access** to give the extension host permission for the current site, then **Refresh** to list the site's cookies.
5. Check the cookies you want to share (or **Select all**), then click **Sync**. The popup confirms `Synced N cookies.`
Settings are saved in `chrome.storage.local`. The extension requests gateway-origin permission before sending, and asks for active-tab host permission before reading cookies.
---
## Security Considerations
- **SSRF protection**: GoClaw applies SSRF filtering to tool inputs — agents cannot be trivially directed to internal network addresses.
- **No-sandbox flag**: The Docker compose config passes `--no-sandbox` which is required inside containers. Do not use this on the host without container isolation.
- **Shared memory**: Chrome is memory-intensive. The sidecar is configured with `shm_size: 2gb` and a 2GB memory limit. Tune this for your workload.
- **Exposed CDP port**: By default, port 9222 is only accessible within the Docker network. Do not expose it publicly — CDP allows full browser control with no authentication.
---
## Examples
**Agent prompt to trigger browser use:**
```
Take a screenshot of https://news.ycombinator.com and show me the top 5 stories.
```
The agent will call `browser` with `open`, then `screenshot` or `snapshot` depending on the task.
**Check browser status in agent conversation:**
```
Are you connected to a browser?
```
The agent calls:
```json
{ "action": "status" }
```
Returns:
```json
{ "running": true, "tabs": 1, "url": "https://example.com" }
```
---
## Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| `failed to start browser: launch Chrome` | Chrome not installed locally | Use Docker sidecar instead |
| `resolve remote Chrome at ws://chrome:9222` | Sidecar not healthy yet | Wait for `service_healthy` or increase startup timeout |
| `snapshot failed` | Page not loaded | Add a `wait` action after `open` |
| Screenshots are blank | GPU rendering issue | Ensure `--disable-gpu` flag is set (already in compose) |
| High memory usage | Many open tabs | Call `close` on tabs when done |
| CDP port exposed publicly | Misconfigured ports | Remove `9222` from host port mappings in production |
---
## What's Next
- [Exec Approval](/exec-approval) — require human sign-off before running commands
- [Hooks & Quality Gates](/hooks-quality-gates) — add pre/post checks to agent actions
---
# Extended Thinking
> Let your agent "think out loud" before answering — better results on complex tasks, at the cost of extra tokens and latency.
## Overview
Extended thinking lets a supported LLM reason through a problem before producing its final reply. The model generates internal reasoning tokens that are not part of the visible response but improve the quality of complex analysis, multi-step planning, and decision-making.
GoClaw supports extended thinking across four provider families — Anthropic, OpenAI-compatible, DashScope (Alibaba Qwen), and Codex (OpenAI ChatGPT OAuth, Responses API) — through a single unified `thinking_level` setting per agent.
## Provider Mapping
Each provider translates `thinking_level` differently:
```mermaid
flowchart TD
CONFIG["Agent config:\nthinking_level = medium"] --> CHECK{"Provider supports\nthinking?"}
CHECK -->|No| SKIP["Send request\nwithout thinking"]
CHECK -->|Yes| MAP{"Provider type?"}
MAP -->|Anthropic| ANTH["budget_tokens: 10,000\nHeader: anthropic-beta\nStrip temperature"]
MAP -->|OpenAI-compat| OAI["reasoning_effort: medium"]
MAP -->|DashScope| DASH["enable_thinking: true\nbudget: 16,384\n⚠ No streaming when tools present"]
ANTH --> SEND["Send to LLM"]
OAI --> SEND
DASH --> SEND
```
### Anthropic
| Level | Budget tokens |
|-------|:---:|
| `low` | 4,096 |
| `medium` | 10,000 |
| `high` | 32,000 |
When thinking is active, GoClaw:
- Adds `thinking: { type: "enabled", budget_tokens: N }` to the request body
- Sets the `anthropic-beta: interleaved-thinking-2025-05-14` header
- **Strips the `temperature` parameter** — Anthropic rejects thinking requests that include temperature
- Auto-adjusts `max_tokens` to `budget_tokens + 8,192` to accommodate thinking overhead
> **Sampling params on Claude 4.6+:** For Claude Opus/Sonnet 4.6+ and Opus 4.7+, GoClaw omits `temperature` (and `top_p`/`top_k`) from **every** request — independent of thinking. These models return `HTTP 400` if sampling params are included. This is broader than the thinking-only strip above. See the [Anthropic provider](/provider-anthropic) page.
### OpenAI-Compatible (OpenAI, Groq, DeepSeek, etc.)
Maps `thinking_level` directly to `reasoning_effort`:
- `low` → `reasoning_effort: "low"`
- `medium` → `reasoning_effort: "medium"`
- `high` → `reasoning_effort: "high"`
Reasoning content arrives in `reasoning_content` during streaming and does not require special passback handling between turns.
> **Kimi Coding (`kimi_coding`):** Kimi is OpenAI-compatible but has **server-side thinking on by default** for `kimi-k2-turbo-preview`. As a result, assistant tool-call messages replayed in history **must carry** a `reasoning_content` field — GoClaw auto-emits an empty string when none was captured, otherwise upstream returns `HTTP 400`. See the [Kimi Coding](/provider-kimi) page.
> **Bailian Coding (`bailian`):** Bailian is a separate OpenAI-compatible Coding endpoint. Its hardcoded catalog includes `qwen3.7-plus` (advertised with Deep Thinking and Visual Understanding), but the DashScope `enable_thinking`/`thinking_budget` injection path described below does **not** apply to Bailian — it is treated as a plain OpenAI-compatible provider. See [Bailian](/provider-bailian).
### DashScope (Alibaba Qwen)
| Level | Budget tokens |
|-------|:---:|
| `low` | 4,096 |
| `medium` | 16,384 |
| `high` | 32,768 |
Thinking is enabled via `enable_thinking: true` plus a `thinking_budget` parameter.
**Per-model guard**: GoClaw checks whether the resolved model is in the supported thinking model list before sending `enable_thinking`. If the model does not support thinking (e.g., an older Qwen2 variant), the parameters are silently omitted and a debug log is emitted. This guard means `thinking_level` on a DashScope agent is safe to set even if you later switch to a non-thinking Qwen model.
**Important limitation**: DashScope cannot stream responses when tools are present — this is a provider-level constraint independent of thinking. Whenever an agent has tools defined, GoClaw automatically falls back to non-streaming mode (single `Chat()` call) and synthesizes chunk callbacks so the event flow remains consistent for clients.
---
## Streaming
When thinking is active, reasoning content streams alongside the regular reply content. Clients receive both separately:
```mermaid
flowchart TD
LLM["LLM generates response"] --> THINK["Thinking tokens\n(internal reasoning)"]
THINK --> CONTENT["Content tokens\n(final response)"]
THINK -->|Stream| CT["StreamChunk\nThinking: 'reasoning text...'"]
CONTENT -->|Stream| CC["StreamChunk\nContent: 'response text...'"]
CT --> CLIENT["Client receives\nthinking + content separately"]
CC --> CLIENT
```
| Provider | Thinking event | Content event |
|----------|---------------|---------------|
| Anthropic | `thinking_delta` in content blocks | `text_delta` in content blocks |
| OpenAI-compat | `reasoning_content` in delta | `content` in delta |
| DashScope | No streaming with tools (falls back to non-streaming) | Same |
| Codex | `OutputTokensDetails.ReasoningTokens` tracked | Standard content |
Thinking tokens are estimated as `character_count / 4` for context window tracking.
> **Channel delivery is separate from provider streaming.** Whether a provider streams reasoning is one decision; whether a chat channel (e.g. Telegram) shows that reasoning to end users is another. Telegram exposes a `reasoning_delivery` setting for this — see the channel documentation for its modes.
---
## Tool Loop Handling
When an agent uses tools, thinking must survive across multiple turns. GoClaw handles this automatically — but the mechanics differ by provider.
```mermaid
flowchart TD
T1["Turn 1: LLM thinks + calls tool"] --> PRESERVE["Preserve thinking blocks\nin raw assistant content"]
PRESERVE --> TOOL["Tool executes,\nresult appended to history"]
TOOL --> T2["Turn 2: LLM receives history\nincluding preserved thinking blocks"]
T2 --> CONTINUE["LLM continues reasoning\nwith full context"]
```
**Anthropic**: Thinking blocks include cryptographic `signature` fields that must be echoed back exactly in subsequent turns. GoClaw accumulates raw content blocks during streaming (including `thinking` type blocks) and re-sends them on the next turn. Dropping or modifying these blocks causes the API to reject the request or produce degraded responses.
**OpenAI-compatible**: Reasoning content is treated as metadata. Each turn's reasoning is independent — no passback is needed.
---
## Limitations
| Provider | Limitation |
|----------|-----------|
| DashScope | Cannot stream when tools are present (provider-level, not thinking-specific) — falls back to non-streaming |
| Anthropic | `temperature` is stripped when thinking is enabled |
| All | Thinking tokens count against the context window budget |
| All | Thinking increases latency and cost proportional to the budget level |
---
## Examples
**Enable medium thinking on an Anthropic agent:**
```json
{
"agent": {
"key": "analyst",
"provider": "claude-opus-4-5",
"thinking_level": "medium"
}
}
```
At `medium`, Anthropic gets `budget_tokens: 10,000`. The agent's visible reply is unchanged — thinking happens internally.
**High thinking for a complex research agent:**
```json
{
"agent": {
"key": "researcher",
"provider": "claude-opus-4-5",
"thinking_level": "high"
}
}
```
This sets `budget_tokens: 32,000`. Use this for tasks that require deep multi-step analysis. Expect higher latency and token cost.
**OpenAI o-series agent with low reasoning:**
```json
{
"agent": {
"key": "quick-reviewer",
"provider": "o4-mini",
"thinking_level": "low"
}
}
```
Maps to `reasoning_effort: "low"` on the OpenAI API.
---
## Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| `temperature` stripped unexpectedly | Anthropic thinking enabled | Expected behavior — Anthropic requires no temperature with thinking |
| DashScope agent slow with tools | Streaming always disabled when tools present | Expected — DashScope provider limitation; reduce tool count if latency matters |
| High context usage | Thinking tokens fill the window | Use `low` or `medium` level; monitor context % in logs |
| No visible thinking output | Thinking is internal by default | Reasoning chunks stream separately; check client WebSocket events |
| Thinking has no effect | Provider doesn't support thinking | Check provider type — only Anthropic, OpenAI-compat, and DashScope are supported |
---
## What's Next
- [Agents Overview](/agents-explained) — per-agent configuration reference
- [Hooks & Quality Gates](/hooks-quality-gates) — validate agent outputs after reasoning
---
# Agent Hooks
> Intercept, observe, or inject behavior at defined points in the agent loop — block unsafe tool calls, auto-audit after writes, inject session context, or notify on stop.
## Overview
GoClaw's hook system attaches lifecycle handlers to agent sessions. Each hook targets a specific **event**, runs a **handler** (shell command, HTTP webhook, or LLM evaluator), and returns an **allow/block** decision for blocking events.
Hooks are stored in the `agent_hooks` DB table (migration `000052`) and managed via the `hooks.*` WebSocket methods or the **Hooks** panel in the Web UI.
## Execution Flow
```mermaid
flowchart TD
EVENT["Lifecycle event fires\ne.g. pre_tool_use"] --> RESOLVE["Dispatcher resolves hooks\nby scope + event + priority"]
RESOLVE --> MATCH{"Matcher / if_expr\ncheck"}
MATCH -->|no match| SKIP["Skip hook"]
MATCH -->|matches| HANDLER["Run handler\n(command / http / prompt)"]
HANDLER -->|allow| NEXT["Continue chain"]
HANDLER -->|block| BLOCKED["Block operation\nFail-closed"]
HANDLER -->|timeout| TIMEOUT_DECISION{"OnTimeout\npolicy"}
TIMEOUT_DECISION -->|block| BLOCKED
TIMEOUT_DECISION -->|allow| NEXT
NEXT --> AUDIT["Write hook_executions row\n+ emit trace span"]
```
---
## Handler Reference
### command
```json
{
"handler_type": "command",
"event": "pre_tool_use",
"scope": "tenant",
"config": {
"command": "bash /path/to/script.sh",
"allowed_env_vars": ["MY_VAR"],
"cwd": "/workspace"
}
}
```
- **Stdin**: JSON-encoded event payload.
- **Exit 0**: allow (optional `{"continue": false}` → block).
- **Exit 2**: block.
- **Other non-zero**: error → fail-closed for blocking events.
- **Env allowlist**: only keys listed in `allowed_env_vars` are passed; prevents secret leakage.
### http
```json
{
"handler_type": "http",
"event": "user_prompt_submit",
"scope": "tenant",
"config": {
"url": "https://example.com/webhook",
"headers": { "Authorization": "" }
}
}
```
- Method: POST, body = event JSON.
- Authorization header values stored AES-256-GCM encrypted; decrypted at dispatch.
- 1 MiB response cap. Retries once on 5xx with 1 s backoff; 4xx fail-closed.
- Expected response body:
```json
{ "decision": "allow", "additionalContext": "...", "updatedInput": {}, "continue": true }
```
- Non-JSON 2xx → allow.
### prompt
```json
{
"handler_type": "prompt",
"event": "pre_tool_use",
"scope": "tenant",
"matcher": "^(exec|shell|write_file)$",
"config": {
"prompt_template": "Evaluate safety of this tool call.",
"model": "haiku",
"max_invocations_per_turn": 5
}
}
```
- `prompt_template` — system-level instruction the evaluator receives.
- `matcher` or `if_expr` — required; prevents firing the LLM on every event.
- Evaluator MUST call a `decide(decision, reason, injection_detected, updated_input)` tool. Free-text responses fail-closed.
- Only `tool_input` reaches the evaluator (anti-injection sandboxing); raw user message is never included.
---
## Matchers
| Field | Description |
|---|---|
| `matcher` | POSIX-ish regex applied to `tool_name`. Example: `^(exec|shell|write_file)$` |
| `if_expr` | [cel-go](https://github.com/google/cel-go) expression over `{tool_name, tool_input, depth}`. Example: `tool_name == "exec" && size(tool_input.cmd) > 80` |
Both optional for `command`/`http`. At least one required for `prompt`.
---
## Config Fields Reference
| Field | Type | Required | Description |
|---|---|---|---|
| `event` | string | yes | Lifecycle event name |
| `handler_type` | string | yes | `command`, `http`, or `prompt` |
| `scope` | string | yes | `global`, `tenant`, or `agent` |
| `name` | string | no | Human-readable label |
| `matcher` | string | no | Tool name regex filter |
| `if_expr` | string | no | CEL expression filter |
| `timeout_ms` | int | no | Per-hook timeout (default 5000, max 10000) |
| `on_timeout` | string | no | `block` (default) or `allow` |
| `priority` | int | no | Higher = runs first (default 0) |
| `enabled` | bool | no | Default true |
| `config` | object | yes | Handler-specific sub-config |
| `agent_ids` | array | no | Restrict to specific agent UUIDs (scope=agent) |
---
## Security Model
- **Edition gating**: `command` handler blocked on Standard at both config-time and dispatch-time (defense in depth).
- **Tenant isolation**: all reads/writes scope by `tenant_id` unless caller is in master scope. Global hooks use a sentinel tenant id.
- **SSRF protection**: HTTP handler validates URLs before request, pins resolved IP, blocks loopback/link-local/private ranges.
- **PII redaction**: audit rows truncate error text to 256 chars; full error encrypted (AES-256-GCM) in `error_detail`.
- **Fail-closed**: any unhandled error in a blocking event yields `block`. Timeouts respect `on_timeout` (default `block` for blocking events).
- **Circuit breaker**: 5 consecutive blocks/timeouts in a 1-minute rolling window auto-disables the hook (`enabled=false`).
- **Loop detection**: sub-agent hook chains bounded at depth 3.
---
## Safeguards Summary
| Safeguard | Default | Overridable per hook |
|---|---|---|
| Per-hook timeout | 5 s | yes (`timeout_ms`, max 10 s) |
| Chain budget | 10 s | no |
| Circuit threshold | 5 blocks in 1 minute | no |
| Prompt per-turn cap | 5 invocations | yes (`max_invocations_per_turn`) |
| Prompt decision cache TTL | 60 s | no |
| Tenant monthly token budget | 1,000,000 tokens | seeded per tenant in `tenant_hook_budget` |
---
## Managing Hooks via WebSocket
All CRUD is available over the `hooks.*` WS methods (see [WebSocket Protocol](/websocket-protocol#hooks)).
**Create a hook:**
```json
{
"type": "req", "id": "1", "method": "hooks.create",
"params": {
"event": "pre_tool_use",
"handler_type": "http",
"scope": "tenant",
"name": "Safety webhook",
"matcher": "^exec$",
"config": { "url": "https://safety.internal/check" }
}
}
```
Response:
```json
{ "type": "res", "id": "1", "ok": true, "payload": { "hookId": "uuid..." } }
```
**Toggle a hook on/off:**
```json
{ "type": "req", "id": "2", "method": "hooks.toggle",
"params": { "hookId": "uuid...", "enabled": false } }
```
**Dry-run test (no audit row written):**
```json
{
"type": "req", "id": "3", "method": "hooks.test",
"params": {
"config": { "event": "pre_tool_use", "handler_type": "command",
"scope": "tenant", "config": { "command": "cat" } },
"sampleEvent": { "toolName": "exec", "toolInput": { "cmd": "ls" } }
}
}
```
---
## Web UI Walkthrough
Navigate to **Hooks** in the sidebar.
1. **Create** — pick event, handler type (`command` greyed out on Standard edition), scope, matcher, then fill the handler-specific sub-form.
2. **Test panel** — fires the hook with a sample event (`dryRun=true`, no audit row written). Shows decision badge, duration, stdout/stderr (command), status code (http), reason (prompt). If the response includes `updatedInput`, a side-by-side JSON diff is rendered.
3. **History tab** — paginated executions from `hook_executions`.
4. **Overview tab** — summary card with event, type, scope, matcher.
---
## Database Schema
Three tables land with migration `000052_agent_hooks`:
**`agent_hooks`** — hook definitions:
| Column | Type | Notes |
|---|---|---|
| `id` | UUID PK | — |
| `tenant_id` | UUID FK | sentinel UUID for global scope |
| `agent_ids` | UUID[] | empty = applies to all agents in scope |
| `event` | VARCHAR(32) | one of the 7 event names |
| `handler_type` | VARCHAR(16) | `command`, `http`, `prompt` |
| `scope` | VARCHAR(16) | `global`, `tenant`, `agent` |
| `config` | JSONB | handler sub-config |
| `matcher` | TEXT | tool name regex (optional) |
| `if_expr` | TEXT | CEL expression (optional) |
| `timeout_ms` | INT | default 5000 |
| `on_timeout` | VARCHAR(16) | `block` or `allow` |
| `priority` | INT | higher fires first |
| `enabled` | BOOL | circuit breaker writes false here |
| `version` | INT | increments on update; busts prompt cache |
| `source` | VARCHAR(16) | `builtin` (read-only) or `user` |
**`hook_executions`** — audit log:
| Column | Notes |
|---|---|
| `hook_id` | `ON DELETE SET NULL` — executions preserved after hook deletion |
| `dedup_key` | Unique index prevents double rows on retry |
| `error` | Truncated to 256 chars |
| `error_detail` | BYTEA, AES-256-GCM encrypted full error |
| `metadata` | JSONB: `matcher_matched`, `cel_eval_result`, `stdout_len`, `http_status`, `prompt_model`, `prompt_tokens`, `trace_id` |
**`tenant_hook_budget`** — per-tenant monthly token limits (prompt handler only).
---
## Observability
Every hook execution emits a trace span named `hook..` (e.g. `hook.prompt.pre_tool_use`) with fields: `status`, `duration_ms`, `metadata.decision`, `parent_span_id`.
Slog keys:
- `security.hook.circuit_breaker` — breaker tripped.
- `security.hook.audit_write_failed` — audit row write error.
- `security.hook.loop_depth_exceeded` — `MaxLoopDepth` violation.
- `security.hook.prompt_parse_error` — evaluator returned malformed structured output.
- `security.hook.budget_deduct_failed` / `budget_precheck_failed` — budget store error.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| HTTP hook always returns `error` | SSRF block on loopback | Use a public/internal URL accessible from the gateway process |
| Prompt hook blocks everything | Evaluator returning free-text (no tool call) | Review `prompt_template`; keep it short + imperative |
| Hook stopped firing | Circuit breaker tripped (5 blocks/min) | Fix upstream cause, then re-enable: `hooks.toggle { enabled: true }` |
| UI `command` radio greyed out | Standard edition | Use `http` or `prompt`, or upgrade to Lite |
| Per-turn cap hit | `max_invocations_per_turn` too low | Raise in hook config; tighten `matcher` to reduce LLM calls |
| Budget exceeded | Tenant spent monthly token budget | Raise `tenant_hook_budget.budget_total` or wait for rollover |
| `handler_type, event, and scope are required` | Missing fields in create payload | Include all three required fields |
---
## Migration from Old Quality Gates
Prior to the hooks system, delegation quality gates were configured inline in the source agent's `other_config.quality_gates` array. That system supported only `delegation.completed` events and two handler types (`command`, `agent`).
The new hooks system replaces it with:
| Old | New |
|---|---|
| `other_config.quality_gates[].event: "delegation.completed"` | `subagent_stop` (non-blocking) or `subagent_start` (blocking) |
| `other_config.quality_gates[].type: "command"` | `handler_type: "command"` (Lite) or `handler_type: "http"` (Standard) |
| `other_config.quality_gates[].type: "agent"` | `handler_type: "prompt"` with an LLM evaluator |
| `block_on_failure: true` + `max_retries` | Built-in blocking semantics; no retry loop needed (block is immediate) |
No data migration required when upgrading from a pre-hooks release. Migration `000052_agent_hooks` creates all three tables cleanly.
---
## What's Next
- [WebSocket Protocol](/websocket-protocol) — full `hooks.*` method reference
- [Exec Approval](/exec-approval) — human-in-the-loop approval for shell commands
- [Extended Thinking](/extended-thinking) — deeper reasoning before producing output
---
# Authentication
> Connect GoClaw to ChatGPT via OAuth — no API key needed, uses your existing OpenAI account.
## Overview
GoClaw supports OAuth 2.0 PKCE authentication for the OpenAI/Codex provider. This lets you use ChatGPT (the `openai-codex` provider) without a paid API key by authenticating through your OpenAI account via browser. Tokens are stored securely in the database and refreshed automatically before expiry.
This flow is distinct from standard API key providers — it is only needed if you want to use the `openai-codex` provider type.
## How It Works
```mermaid
flowchart TD
UI["Web UI: click Connect ChatGPT"] --> START["POST /v1/auth/openai/start"]
START --> PKCE["Gateway generates\nPKCE verifier + challenge"]
PKCE --> SERVER["Callback server starts\non port 1455"]
SERVER --> URL["Auth URL returned to UI"]
URL --> BROWSER["User opens browser\n→ auth.openai.com"]
BROWSER --> LOGIN["User logs in to OpenAI"]
LOGIN --> CB["Browser redirects to\nlocalhost:1455/auth/callback"]
CB --> EXCHANGE["Code exchanged for tokens\nat auth.openai.com/oauth/token"]
EXCHANGE --> SAVE["Access token → llm_providers\nRefresh token → config_secrets"]
SAVE --> READY["openai-codex provider\nregistered and ready"]
```
The gateway starts a temporary HTTP server on port **1455** to receive the OAuth callback. This port must be reachable from the browser (i.e. accessible on localhost when using the web UI locally, or via port forwarding for remote servers).
---
## Starting the OAuth Flow
### Via Web UI
1. Open the GoClaw web dashboard
2. Navigate to **Providers** → **ChatGPT OAuth**
3. Click **Connect** — the gateway calls `POST /v1/auth/openai/start` and returns an auth URL
4. Your browser opens `auth.openai.com` — log in and approve access
5. The callback lands on `localhost:1455/auth/callback` — tokens are saved automatically
### Remote / VPS Environments
If the browser callback can't reach port 1455 on the server, use the **manual redirect URL** fallback:
1. Start the flow via web UI — copy the auth URL
2. Open the auth URL in your local browser
3. After approving, your browser tries to redirect to `localhost:1455/auth/callback` and fails (since the server is remote)
4. Copy the full redirect URL from the browser address bar (it starts with `http://localhost:1455/auth/callback?code=...`)
5. Paste it into the web UI's manual callback field — the UI calls `POST /v1/auth/openai/callback` with the URL
6. The gateway extracts the code, completes the exchange, and saves the tokens
---
## CLI Commands
The `./goclaw auth` subcommand talks to the running gateway to check and manage OAuth state.
### Check Status
```bash
./goclaw auth status
```
Output when authenticated:
```
OpenAI OAuth: active (provider: openai-codex)
Use model prefix 'openai-codex/' in agent config (e.g. openai-codex/gpt-4o).
```
Output when not authenticated:
```
No OAuth tokens found.
Use the web UI to authenticate with ChatGPT OAuth.
```
The command hits `GET /v1/auth/openai/status` on the running gateway. The gateway URL is resolved from environment variables:
| Variable | Default |
|----------|---------|
| `GOCLAW_GATEWAY_URL` | — (overrides host+port) |
| `GOCLAW_HOST` | `127.0.0.1` |
| `GOCLAW_PORT` | `3577` |
Set `GOCLAW_TOKEN` to authenticate the CLI request if the gateway requires a token.
### Logout
```bash
./goclaw auth logout
# or explicitly:
./goclaw auth logout openai
```
This calls `POST /v1/auth/openai/logout`, which:
1. Deletes the `openai-codex` provider row from `llm_providers`
2. Deletes the refresh token from `config_secrets`
3. Unregisters the `openai-codex` provider from the in-memory registry
---
## Gateway OAuth Endpoints
All endpoints require `Authorization: Bearer `.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/auth/openai/status` | Check if OAuth is active and token is valid — returns `{ authenticated, provider_name? }` |
| `POST` | `/v1/auth/openai/start` | Start OAuth flow — returns `{ auth_url }` or `{ status: "already_authenticated" }` |
| `POST` | `/v1/auth/openai/callback` | Submit redirect URL for manual exchange — body: `{ redirect_url }` — returns `{ authenticated, provider_name, provider_id }` |
| `POST` | `/v1/auth/openai/logout` | Remove stored tokens and unregister provider — returns `{ status: "logged out" }` |
---
## Token Storage and Refresh
GoClaw stores OAuth tokens across two tables:
| Storage | What is stored |
|---------|---------------|
| `llm_providers` | Access token (as `api_key`), expiry timestamp in `settings` JSONB |
| `config_secrets` | Refresh token under key `oauth.openai-codex.refresh_token` |
The `DBTokenSource` handles the full lifecycle:
- **Cache**: the access token is cached in memory and reused until within 5 minutes of expiry
- **Auto-refresh**: when the token is about to expire, the refresh token is retrieved from `config_secrets` and a new token is fetched from `auth.openai.com/oauth/token`
- **Persistence**: both the new access token (in `llm_providers`) and new refresh token (in `config_secrets`) are written back to the database after refresh
- **Graceful degradation**: if refresh fails but a token still exists, the existing token is returned and a warning is logged — the provider stays usable until the token actually expires
The OAuth scopes requested during login are:
```
openid profile email offline_access api.connectors.read api.connectors.invoke
```
`offline_access` is what grants the refresh token for long-lived sessions.
---
## Using the Provider in Agent Config
Once authenticated, reference the provider with the `openai-codex/` prefix:
```json
{
"agent": {
"key": "my-agent",
"provider": "openai-codex/gpt-4o"
}
}
```
The `openai-codex` provider name is fixed — it matches the `DefaultProviderName` constant in the oauth package.
---
## Examples
**Check status after onboarding:**
```bash
source .env.local
./goclaw auth status
```
**Force re-authentication (logout then reconnect via UI):**
```bash
./goclaw auth logout
# then open web UI → Providers → Connect ChatGPT
```
---
## Common Issues
| Issue | Cause | Fix |
|-------|-------|-----|
| `cannot reach gateway at http://127.0.0.1:3577` | Gateway not running | Start gateway first: `./goclaw` |
| `failed to start OAuth flow (is port 1455 available?)` | Port 1455 in use | Stop whatever is using port 1455 |
| Callback fails on remote server | Browser can't reach server port 1455 | Use the manual redirect URL flow (paste URL into web UI) |
| `token invalid or expired` from status endpoint | Refresh failed | Run `./goclaw auth logout` then re-authenticate |
| `unknown provider: xyz` from logout | Unsupported provider name | Only `openai` is supported: `./goclaw auth logout openai` |
| Agent gets 401 from ChatGPT | Token expired and refresh failed | Re-authenticate via web UI |
---
## What's Next
- [Providers Overview](/providers-overview) — all supported LLM providers and how to configure them
- [Hooks & Quality Gates](/hooks-quality-gates) — add validation to agent outputs
---
# API Keys & RBAC
> Manage API keys with role-based access control for multi-user and programmatic access deployments.
## Overview
GoClaw uses a **5-layer permission system**. API keys and roles sit at layer 1 — gateway authentication. When a request arrives, GoClaw checks the `Authorization: Bearer ` header, resolves the token to a role, and enforces that role against the method being called.
> API keys and tenant RBAC are only the first layer. Agent-config permissions, group/channel scopes, and channel-context credentials are documented in the [Permission Matrix](/permission-matrix).
Three roles exist:
| Role | Level | Description |
|------|-------|-------------|
| `admin` | 3 | Full access — can manage API keys, agents, config, teams, and everything below |
| `operator` | 2 | Read + write — can chat, manage sessions, crons, approvals, pairing |
| `viewer` | 1 | Read-only — can list/get resources but cannot modify anything |
Roles are **not set directly on an API key**. Instead, you assign **scopes** and GoClaw derives the effective role from those scopes at runtime.
## Method Permissions
| Methods | Required role |
|---------|---------------|
| `api_keys.list`, `api_keys.create`, `api_keys.revoke` | admin |
| `config.apply`, `config.patch` | admin |
| `agents.create`, `agents.update`, `agents.delete` | admin |
| `channels.toggle` | admin |
| `teams.list`, `teams.create`, `teams.delete` | admin |
| `pairing.approve`, `pairing.revoke` | admin |
| `chat.send`, `chat.abort` | operator |
| `sessions.delete`, `sessions.reset`, `sessions.patch` | operator |
| `cron.create`, `cron.update`, `cron.delete`, `cron.toggle` | operator |
| `approvals.*`, `exec.approval.*` | operator |
| `pairing.*`, `device.pair.*` | operator |
| `send` | operator |
| Everything else (list, get, read) | viewer |
---
## Backward Compatibility
If `gateway.token` is empty (no gateway token configured), all requests — including unauthenticated ones — are granted `RoleAdmin` access automatically. This lets self-hosted setups work without strict auth. Once a token is set, all requests must provide valid credentials or they receive `401 Unauthorized`.
---
## Authentication
All API requests use HTTP Bearer token authentication:
```
Authorization: Bearer
```
The gateway also accepts the static token from `auth.token` in `config.json`. That token acts as a super-admin with no scope restrictions. API keys are the recommended way to grant scoped, revocable access to external systems.
---
## Key Format
API keys follow the format `goclaw_` + 32 lowercase hex characters (16 random bytes, 128-bit entropy):
```
goclaw_a1b2c3d4e5f6789012345678901234567890abcdef
```
The **display prefix** shown in list responses is `goclaw_` + the first 8 hex chars of the random part (e.g., `goclaw_a1b2c3d4`). This lets you identify a key in the UI without storing the secret.
**Show-once pattern:** the raw `key` field is returned only in the create response. All subsequent list/get calls return only `prefix`. Copy the key immediately after creation — it cannot be retrieved again.
---
## Creating an API Key
**Requires: admin role**
```bash
curl -X POST http://localhost:8080/v1/api-keys \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "ci-pipeline",
"scopes": ["operator.read", "operator.write"],
"expires_in": 2592000
}'
```
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Display name, max 100 characters |
| `scopes` | yes | One or more valid scope strings |
| `expires_in` | no | TTL in seconds; omit or set `null` for a non-expiring key |
Response (HTTP 201):
```json
{
"id": "01944f3a-1234-7abc-8def-000000000001",
"name": "ci-pipeline",
"prefix": "goclaw_a1b2c3d4",
"key": "goclaw_a1b2c3d4e5f6789012345678901234567890abcdef",
"scopes": ["operator.read", "operator.write"],
"expires_at": "2026-04-15T00:00:00Z",
"created_at": "2026-03-16T10:00:00Z"
}
```
**The `key` field is shown only once.** Store it immediately — it cannot be retrieved again. Only the SHA-256 hash is kept in the database.
---
## Listing API Keys
**Requires: admin role**
```bash
curl http://localhost:8080/v1/api-keys \
-H "Authorization: Bearer "
```
Response (HTTP 200):
```json
[
{
"id": "01944f3a-1234-7abc-8def-000000000001",
"name": "ci-pipeline",
"prefix": "goclaw_a1b2c3d4",
"scopes": ["operator.read", "operator.write"],
"expires_at": "2026-04-15T00:00:00Z",
"last_used_at": "2026-03-16T09:55:00Z",
"revoked": false,
"created_at": "2026-03-16T10:00:00Z"
}
]
```
The `prefix` field (first 8 characters) lets you identify a key without storing the secret. The raw key is never returned after creation.
---
## Revoking an API Key
**Requires: admin role**
```bash
curl -X POST http://localhost:8080/v1/api-keys//revoke \
-H "Authorization: Bearer "
```
Response (HTTP 200):
```json
{ "status": "revoked" }
```
Revocation takes effect immediately — the key is marked revoked in the database and the in-process cache is cleared via pubsub.
---
## WebSocket RPC Methods
API key management is also available over the WebSocket connection. All three methods require `operator.admin` scope.
### List keys
```json
{ "type": "req", "id": "1", "method": "api_keys.list" }
```
### Create a key
```json
{
"type": "req",
"id": "2",
"method": "api_keys.create",
"params": {
"name": "dashboard-readonly",
"scopes": ["operator.read"]
}
}
```
### Revoke a key
```json
{
"type": "req",
"id": "3",
"method": "api_keys.revoke",
"params": { "id": "01944f3a-1234-7abc-8def-000000000001" }
}
```
---
## Security Details
### SHA-256 hashing
Raw API keys are never stored. On creation, GoClaw generates a random key, stores only its `SHA-256` hex digest, and returns the raw value once. Every inbound request is hashed before the database lookup.
### In-process cache with TTL
After the first lookup, the resolved key data and role are cached in memory for **5 minutes**. This eliminates repeated database round-trips on busy endpoints. The cache is keyed by hash — not the raw token.
### Negative cache
If an unknown token is presented (e.g., a typo or a revoked key that has since been evicted), GoClaw caches the miss as a **negative entry** to avoid hammering the database. The negative cache is capped at **10,000 entries** to prevent memory exhaustion from token-spraying attacks.
### Cache invalidation
When a key is created or revoked, a `cache.invalidate` event is broadcast on the internal message bus. All active HTTP handlers clear their caches immediately — no stale entries survive a revocation.
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| `401 Unauthorized` on key management endpoints | Caller is not admin role | Use the gateway token or a key with `operator.admin` scope |
| `400 invalid scope: X` | Scope string is not recognised | Use only: `operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing` |
| `400 name is required` | `name` field missing or empty | Add `"name": "..."` to the request body |
| `400 scopes is required` | `scopes` array is empty or missing | Include at least one scope |
| Key shows `revoked: false` after revocation | Cache TTL (5 min) not yet expired | Wait up to 5 minutes or restart the gateway |
| Raw key lost after creation | Raw key is only returned once by design | Revoke the key and create a new one |
| `404` on revoke | Key ID is wrong or already revoked | Double-check the UUID from the list endpoint |
---
## What's Next
- [Permission Matrix](/permission-matrix) — agent-config permissions, group/channel scopes, and the full authorization-layer table
- [Authentication & OAuth](/authentication) — gateway token and OAuth flow
- [Exec Approval](/exec-approval) — require `operator.approvals` scope
- [Security Hardening](/deploy-security) — full 5-layer permission overview
- [CLI Credentials](./cli-credentials.md) — SecureCLI: inject credentials into CLI tools (gh, aws, gcloud) without exposing secrets to the agent
---
# Agent Permission Matrix
> The full set of authorization layers that decide what an agent can do across channels, groups, and workspaces — and how those layers combine.
## Overview
A single agent action (sending a reply, writing a workspace file, editing a context file) passes through several independent permission layers. Each layer can allow or deny, and they stack: an action is only permitted when every applicable layer allows it. This page maps those layers, the agent-config permission rules you can grant from chat, the per-channel matrix, and the rules that most often surprise people (Zalo context writes and channel-context credentials).
If you only need gateway-token roles, see [API Keys & RBAC](/api-keys-rbac) — that is layer 1. This page covers the layers above it.
## Permission Layers
| Layer | Scope | Notes |
|-------|-------|-------|
| Tenant RBAC | Dashboard, HTTP, WebSocket RPC | Viewer / operator / admin / owner. Admin-only methods include `config.permissions.*`. |
| Agent ownership / share | Which agents a user can see and manage | Owner plus explicit shares; see [Sharing & Access Control](/sharing-and-access). |
| Channel membership | Platform delivery | The platform can still reject outbound delivery after GoClaw allows the action. |
| Agent config permissions | Config mutations driven from chat | Matched by `agent_id`, `scope`, `config_type`, and `user_id`, including wildcard rows. |
| Workspace file boundary | Filesystem access | Prevents path escape and unsupported writes. |
| Context file boundary | Agent identity / context files | Protected files are routed to the store and require group writer permission in group contexts. |
| Channel context capabilities | MCP + Secure CLI tool execution | Credential precedence: user credentials > context credentials/grants > agent grants > global defaults. |
## Agent Config Permission Rows
Agents can have parts of their configuration changed from chat (for example, enabling a file writer or a heartbeat). Each permission rule is a row with four fields:
| Field | Examples | Meaning |
|-------|----------|---------|
| `scope` | `agent`, `group:*`, `group:zalo:123`, `group:telegram:-100`, `*` | Where the grant applies. |
| `config_type` | `file_writer`, `heartbeat`, `cron`, `context_files`, `*` | Which family of actions the grant covers. |
| `user_id` | `123456`, `zalo-user-id`, `*` | Who the grant covers. `*` grants every member in the selected scope. |
| `permission` | `allow`, `deny` | The decision. A deny can override a broader allow. |
### Effective precedence
When several rows match a request, GoClaw resolves them top-down and stops at the first that applies:
1. Individual deny.
2. Individual allow.
3. Scope/user wildcard deny.
4. Scope/user wildcard allow.
5. Default deny.
So a specific `deny` for one user always beats a wildcard `allow`, and anything not matched is denied by default.
## Channel Matrix
What an agent can do depends on where the request came from. This table summarizes the common channel contexts.
| Channel context | Read agent output | Send reply | Write workspace file | Write protected context file | Grant all members |
|-----------------|-------------------|------------|----------------------|------------------------------|-------------------|
| Dashboard | RBAC controlled | N/A | Admin/operator path, then workspace boundary | Admin path, then context interceptor | Use the Permissions tab |
| Direct message | Agent/session access | Channel adapter | Allowed by workspace boundary | Allowed by agent/context rules | Usually not needed |
| Telegram group | Group scope + sender ID | Channel adapter | Requires `file_writer` when group-gated | Requires `context_files` or `file_writer` and a real sender | `scope=group:telegram:`, `user_id=*` |
| Zalo group | Group scope + sender ID | Channel adapter, group thread metadata | Requires `file_writer` when group-gated | Requires `context_files` or `file_writer` and a real sender | `scope=group:zalo:`, `user_id=*` |
| Discord guild/channel | Guild scope + sender ID | Channel adapter | Requires `file_writer` when guild-gated | Requires `context_files` or `file_writer` and a real sender | `scope=guild:` or matching group scope, `user_id=*` |
| Scheduled / proactive run | System sender | Channel adapter | Denied for group-gated writes unless elevated context | Denied for protected group context writes | Configure explicit rules, or run from a dashboard/admin context |
## Zalo Context-Write Rule
A common Zalo group failure is an agent trying to write a protected context file — `SOUL.md`, `IDENTITY.md`, `AGENTS.md`, `USER.md`, `USER_PREDEFINED.md`, or `CAPABILITIES.md` — from a group session where the acting sender is missing. Protected context writes go through the group permission gate, which requires:
- `sender_id` is a **real platform user**, not empty or synthetic.
- `user_id` identifies the group scope, for example `group:zalo:`.
- The sender matches a `context_files` allow (or a legacy `file_writer` allow), including wildcard rows such as `user_id="*"`.
- Missing tenant context or permission-store errors **fail closed** — the write is denied rather than allowed.
If a Zalo group write is being rejected, check that the message carries a real sender and that a matching `context_files` rule exists for that group scope.
## Permissions Tab (UX)
The dashboard Permissions tab is a full matrix editor for the rows above:
| Control | Behavior |
|---------|----------|
| User/contact picker | Accepts explicit user IDs and contact search results. |
| All members button | Sets `user_id="*"` for the current rule. |
| Config type selector | `file_writer`, `heartbeat`, `cron`, `context_files`, or `*`. |
| Scope selector | Known groups, `group:*`, `agent`, or `*`. |
| Check access | Calls `config.permissions.check` and shows the effective allow/deny decision before or after saving. |
Use **Check access** to preview the effective decision for a specific user and scope without saving — it runs the same precedence resolution the runtime uses.
## Channel Context Capabilities
Channel instances expose stored contexts in the dashboard and API. The base context is the channel instance itself; group contexts come from stored channel contacts. Each capability row combines MCP and Secure CLI visibility for that context — source, enabled state, tool allow/deny lists, and whether a credential is present.
Context credential rows **never return secret material**. They project only metadata such as `has_api_key`, `has_env`, `credential_source`, and key names where available. Writes are tenant-admin gated, and runtime resolution carries a `ChannelContextScope` so grants and credentials apply only to the matching channel/group scope.
### Channel-context credential precedence
When a tool runs in a channel context, GoClaw resolves credentials in this order — first match wins:
1. User credentials.
2. Context credentials / grants.
3. Agent grants.
4. Global defaults.
This mirrors the typed-credential precedence used by the git adapter (see [CLI Credentials](/cli-credentials)).
## Security Notes
- A wildcard `user_id="*"` should be easy to grant but always **visually explicit** — it expands access to every member in scope.
- Synthetic senders stay denied for group file/context writes, so a system turn never inherits permissions from a real user it isn't.
- Permission-store errors **fail closed** at group mutation boundaries.
- Backend validation rejects unknown config types and permissions before any rule is written.
- Platform send permissions are separate from GoClaw permissions: a channel adapter may still reject delivery even when GoClaw allows the agent action.
## Common Issues
| Problem | Solution |
|---------|----------|
| Zalo group context write rejected | Ensure the message has a real `sender_id` and a matching `context_files` (or `file_writer`) allow for `group:zalo:`. |
| Wildcard allow not taking effect | An individual `deny` beats a wildcard `allow`. Remove the deny or scope it more narrowly. |
| Scheduled run can't write a group file | Proactive/system runs are denied for group-gated writes — configure an explicit rule or run from a dashboard/admin context. |
| Reply sent by GoClaw never arrives | The channel adapter rejected delivery — GoClaw permissions and platform send permissions are independent. |
## What's Next
- [API Keys & RBAC](/api-keys-rbac) — layer 1: gateway-token roles and scopes
- [Sharing & Access Control](/sharing-and-access) — agent ownership and shares
- [CLI Credentials](/cli-credentials) — typed credentials and channel-context credential precedence
- [Security Hardening](/deploy-security) — the full five-layer security overview
---
# CLI Credentials
> Securely store and manage named credential sets for shell tool execution, with per-agent access control via grants.
## Overview
CLI Credentials let you define named credential sets (API keys, tokens, connection strings) that agents can reference when running shell commands via the `exec` tool — without exposing secrets in the system prompt or conversation history.
Each credential is stored as a **secure CLI binary** — a named configuration that maps a binary (e.g. `gh`, `gcloud`, `aws`) to an AES-256-GCM encrypted set of environment variables. When an agent runs the binary, GoClaw decrypts the env vars and injects them into the child process at execution time.
## Global vs Per-Agent Binaries
Since migration 036, the access model uses a **grants system** instead of per-binary agent assignment:
- **Global binaries** (`is_global = true`): available to all agents unless a grant overrides settings
- **Restricted binaries** (`is_global = false`): only accessible to agents that have an explicit grant
This separates credential definition from access control, allowing you to define a binary once and grant it to specific agents with optional per-agent overrides.
```
secure_cli_binaries (credential + defaults)
│
├── is_global = true → all agents can use it
└── is_global = false → only agents with a grant
│
└── secure_cli_agent_grants (per-agent override)
├── deny_args (NULL = use binary default)
├── deny_verbose (NULL = use binary default)
├── timeout_seconds (NULL = use binary default)
├── tips (NULL = use binary default)
├── enabled
└── encrypted_env (BYTEA, AES-256-GCM — optional per-grant env override)
```
## Agent Grants
The `secure_cli_agent_grants` table links a binary to a specific agent and optionally overrides any of the binary's default settings. `NULL` fields inherit the binary default.
| Field | Behaviour |
|-------|-----------|
| `deny_args` | Override forbidden argument patterns for this agent |
| `deny_verbose` | Override verbose flag stripping for this agent |
| `timeout_seconds` | Override process timeout for this agent |
| `tips` | Override the hint injected into TOOLS.md for this agent |
| `enabled` | Disable a grant without deleting it |
| `encrypted_env` | Optional per-grant env-var overrides (AES-256-GCM encrypted at rest) |
When an agent runs a binary, GoClaw resolves settings in this order:
1. Binary defaults
2. Grant overrides (any non-null fields replace the binary default)
3. Per-grant `encrypted_env` is decrypted and merged into the child process environment at execution time (overrides binary-level env vars for this agent only)
## Per-Agent Env Overrides
Since migration `000058`, each `secure_cli_agent_grants` row can carry an optional `encrypted_env` column (BYTEA, AES-256-GCM). This lets you give one agent a different set of environment variables for the same binary — for example, a different AWS account, a separate API key, or a staging endpoint — without creating a separate binary definition.
**How it works:**
- On grant create/update, send `env_vars` (a plaintext `string → string` map) in the request body.
- GoClaw validates the keys against the denylist, then encrypts and persists them in `encrypted_env`.
- Plaintext values are never stored or logged; the store layer encrypts before write and decrypts on read.
- List and get responses expose only `env_keys` (sorted list of key names) and `env_set` (boolean). Values are never returned except via the `env:reveal` endpoint.
**Create a grant with env overrides:**
```bash
curl -X POST http://localhost:8080/v1/cli-credentials/{id}/agent-grants \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "019...",
"env_vars": {
"AWS_PROFILE": "staging",
"AWS_DEFAULT_REGION": "us-west-2"
}
}'
```
Response (`201 Created`) includes `env_keys` but no values:
```json
{
"id": "019...",
"binary_id": "019...",
"agent_id": "019...",
"env_keys": ["AWS_DEFAULT_REGION", "AWS_PROFILE"],
"env_set": true,
"enabled": true,
"created_at": "2026-05-21T00:00:00Z",
"updated_at": "2026-05-21T00:00:00Z"
}
```
**Update env vars on an existing grant:**
Send `env_vars` in the `PUT` body. Three-state semantics:
- **Absent** — existing env is unchanged
- **`null`** — clears the env override (removes `encrypted_env`)
- **`{...}`** — replaces the entire env map (empty `{}` treated the same as `null`)
```bash
curl -X PUT http://localhost:8080/v1/cli-credentials/{id}/agent-grants/{grantId} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"env_vars": null}'
```
## Revealing Decrypted Env Vars
`POST /v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal` returns the decrypted plaintext env vars for a specific grant. The endpoint is POST (not GET) to prevent HTTP caching and satisfy CSRF semantics.
**Security controls:**
- Requires `admin` role scoped to the correct tenant — master-scope callers are rejected.
- Rate-limited to **10 reveals per minute per authenticated user** (burst of 3). Returns `429` when exceeded.
- Response headers include `Cache-Control: no-store` to prevent proxy caching.
- Every call is audited: caller ID, tenant ID, grant ID, binary ID, and timestamp are logged at INFO level.
```bash
curl -X POST http://localhost:8080/v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal \
-H "Authorization: Bearer $TOKEN"
```
Response:
```json
{
"env_vars": {
"AWS_PROFILE": "staging",
"AWS_DEFAULT_REGION": "us-west-2"
}
}
```
Returns `{"env_vars": {}}` when no env override is set for the grant.
## Env Denylist
Not all environment variable names are accepted. GoClaw rejects keys that could allow privilege escalation, shell injection, TLS bypass, or exfiltration.
**Key shape requirement:** keys must match `^[A-Z_][A-Z0-9_]*$` — uppercase, digits, underscores only. Lowercase, spaces, and special characters (including Shellshock-class function definitions) are rejected.
**Exact-match denials:**
| Key | Reason |
|-----|--------|
| `PATH`, `HOME`, `USER`, `SHELL`, `PWD` | Core shell/user identity |
| `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT` | Dynamic linker hijack |
| `NODE_OPTIONS`, `NODE_PATH` | Node.js code injection |
| `PYTHONPATH`, `PYTHONHOME`, `PYTHONSTARTUP` | Python path/startup injection |
| `GIT_SSH_COMMAND`, `GIT_SSH`, `GIT_EXEC_PATH`, `GIT_CONFIG_SYSTEM` | Git command injection |
| `SSH_AUTH_SOCK` | SSH key forwarding |
| `BASH_ENV`, `ENV` | Non-interactive shell sourcing |
| `PROMPT_COMMAND` | Shell prompt execution |
| `PERL5LIB`, `RUBYOPT` | Perl/Ruby library injection |
| `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY` | Exfiltration channel / proxy bypass |
| `SSL_CERT_FILE`, `SSL_CERT_DIR`, `CURL_CA_BUNDLE` | TLS CA override (MitM) |
| `IFS` | Shell Internal Field Separator injection |
**Prefix-match denials:** any key beginning with `DYLD_`, `GOCLAW_`, `LD_`, or `NPM_CONFIG_` is rejected.
**Limits:** maximum 50 keys per grant; maximum 4 096 bytes per value; values must not contain NUL bytes or newlines.
A `400` response on create/update includes the rejected key names in `rejected_keys`:
```json
{
"error": "env keys denied: LD_PRELOAD, PATH",
"rejected_keys": "LD_PRELOAD,PATH"
}
```
## REST API
All grant endpoints are nested under the binary resource and require the `admin` role.
### List grants for a binary
```
GET /v1/cli-credentials/{id}/agent-grants
```
```json
{
"grants": [
{
"id": "019...",
"binary_id": "019...",
"agent_id": "019...",
"deny_args": null,
"timeout_seconds": 60,
"enabled": true,
"env_keys": [],
"env_set": false,
"created_at": "2026-04-05T00:00:00Z",
"updated_at": "2026-04-05T00:00:00Z"
}
]
}
```
### Create a grant
```
POST /v1/cli-credentials/{id}/agent-grants
```
```json
{
"agent_id": "019...",
"timeout_seconds": 120,
"tips": "Use --output json for all commands",
"env_vars": {
"MY_API_KEY": "secret-value"
}
}
```
Omitted fields (`deny_args`, `deny_verbose`, `tips`, `enabled`, `env_vars`) default to `null` / `true`. `env_vars` values are encrypted at rest; only key names are returned in subsequent list/get calls.
### Get a grant
```
GET /v1/cli-credentials/{id}/agent-grants/{grantId}
```
### Update a grant
```
PUT /v1/cli-credentials/{id}/agent-grants/{grantId}
```
Send only the fields to change. Allowed fields: `deny_args`, `deny_verbose`, `timeout_seconds`, `tips`, `enabled`, `env_vars`.
### Delete a grant
```
DELETE /v1/cli-credentials/{id}/agent-grants/{grantId}
```
Deleting a grant from a restricted binary (`is_global = false`) immediately revokes the agent's access to that binary.
### Reveal env vars for a grant
```
POST /v1/cli-credentials/{id}/agent-grants/{grantId}/env:reveal
```
Returns the decrypted plaintext env vars. Rate-limited to 10 calls/minute per user. See [Revealing Decrypted Env Vars](#revealing-decrypted-env-vars) for full details.
## Typed Credential Adapters
The sections above describe the **legacy env-paste model** — you paste arbitrary environment variables and GoClaw injects them verbatim into the child process. That works for tools that read auth from a single stable env var (`GH_TOKEN`, `AWS_ACCESS_KEY_ID`, …), but it fails for tools like `git` that read credentials from config files, credential helpers, or per-remote URLs — pasting a PAT into `GIT_TOKEN` does nothing.
**Typed credential adapters** solve this. Instead of pasting raw env vars, you choose a credential *type*, and GoClaw routes the credential through a server-side adapter that knows how to inject it correctly and securely for that specific tool.
### Credential types
A typed credential row carries a `credential_type`:
| `credential_type` | Meaning |
|-------------------|---------|
| `NULL` / `env` | Legacy env passthrough — env vars injected verbatim, exactly as before. No host scoping. |
| `pat` | Personal Access Token, for HTTPS git remotes (GitHub/GitLab/Gitea). Requires a `host_scope`. |
| `ssh_key` | SSH private key (PEM), for git over SSH. Requires a `host_scope`. |
`NULL`/`env` rows are never migrated — existing legacy credentials keep working unchanged. Typed adapters are opt-in per credential.
### Agent credentials (default git path)
Agent credentials are the **default** path for git auth. They avoid channel-user ID ambiguity: the selected agent owns the credential, and anyone allowed to use that agent can cause it to run git with the stored credential.
Agent credentials live in the `secure_cli_agent_credentials` table (migration `000077`), which stores the typed secret material **separately** from the `secure_cli_agent_grants` policy row. There is one credential per `(agent, binary)`.
**Add an agent credential (UI):**
1. Open **Packages → CLI Credentials**.
2. Pick the `git` row and open **Agent Access**.
3. On the **Credential** tab, select the agent.
4. Choose **Credential Type**: `Personal Access Token` or `SSH Private Key`.
5. Enter the **Host Scope** (required for PAT/SSH): the hostname the credential authenticates to (e.g. `github.com`, `gitlab.example.com`, `gitea.internal:8443`).
6. Paste the token (PAT) or the unencrypted PEM body (SSH).
7. Save.
The **Agent Access** dialog has two tabs:
- **Credential** — pick the agent, credential type, host scope, and secret (above).
- **Access policy** — change deny args, timeout, tips, or env overrides for that agent (the `secure_cli_agent_grants` row).
Policy and secret storage stay separate internally, but you manage them as one access decision in this single dialog. The stored secret is AES-256-GCM encrypted and can never be read back — editing the row shows a `••••••••` placeholder.
### Effective credential precedence
When git runs, GoClaw resolves which typed credential to inject in this order — the **first** match wins:
1. **User override** — a per-user typed credential (Advanced user overrides, below).
2. **Channel/context credential** — a credential scoped to the channel or group context the run originated from.
3. **Agent credential** — the agent's own `secure_cli_agent_credentials` row. This is the default trust boundary.
4. **Binary-level env defaults** — the legacy passthrough env on the binary definition.
Granting an agent access to git effectively grants use of its stored git credential, so the agent credential is the default boundary unless a higher layer (user override or channel/context credential) is present.
### Advanced user overrides
Per-user credentials remain available for personal overrides and backward compatibility. Use them only when a stable tenant user ID is the intended credential boundary — they sit **above** agent credentials in the precedence order.
Manage them in the dashboard under **Packages → CLI Credentials → Advanced User Overrides → Add**: select the user, choose the credential type (`Personal Access Token` or `SSH Private Key`), enter the **Host Scope**, and paste the secret. The stored secret is AES-256-GCM encrypted and can never be read back — leaving the secret field blank on edit preserves the stored value, typing a new value replaces it.
These rows live in the `secure_cli_user_credentials` table.
### Agent credentials REST API
The agent-credentials endpoints manage the typed secret for one `(binary, agent)` pair. They require the `admin` role.
#### List agent credentials for a binary
```
GET /v1/cli-credentials/{id}/agent-credentials
```
Returns the agents that have a stored credential for this binary, with metadata only (credential type, host scope, key presence) — never the secret.
#### Get one agent's credential
```
GET /v1/cli-credentials/{id}/agent-credentials/{agentId}
```
#### Set (create or replace) an agent's credential
```
PUT /v1/cli-credentials/{id}/agent-credentials/{agentId}
```
Send `credential_type`, `host_scope`, and the secret (`env_vars` for `env`, or the typed PAT/SSH key body). The secret is encrypted at rest and never returned.
#### Delete an agent's credential
```
DELETE /v1/cli-credentials/{id}/agent-credentials/{agentId}
```
Removes the stored secret. The agent's `secure_cli_agent_grants` policy row (deny args, timeout, etc.) is unaffected — delete the grant separately to revoke access.
### The git adapter
The `git` adapter is the first shipped typed adapter. It injects credentials **only** for network subcommands:
```
clone fetch pull push submodule
```
Any other subcommand (`status`, `log`, `diff`, `commit`, `branch`, …) is a local operation and runs **uncredentialed** — no injection, no audit-log line.
**PAT flow.** The token is injected through environment variables, never on `argv`:
```
GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=http.https:///.extraheader
GIT_CONFIG_VALUE_0=Authorization: Basic base64("x-access-token:")
```
The header value is HTTP Basic auth: the literal username `x-access-token` and your token joined by a colon, base64-encoded. Because the token lives in an env value (not a command-line flag), it never appears in `ps`, `/proc//cmdline`, or shell history. The injected vars are scoped to the spawned `git` process only — GoClaw's own environment and sibling exec calls never see them.
The raw token, the base64 payload, **and** the full `Authorization: Basic …` header are all registered with the output scrubber, so none of the three can leak back to the agent through stdout, stderr, error messages, or the audit log.
**SSH flow.** The PEM key is written to a `0600`-mode tmpfile in the system temp dir (prefix `goclaw-gitkey-*`), and `GIT_SSH_COMMAND` is set to:
```
ssh -i -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new
```
`BatchMode=yes` means SSH never prompts and fails fast in the agent context. `StrictHostKeyChecking=accept-new` accepts unknown host keys on **first contact (TOFU)**. Pre-seed `~/.ssh/known_hosts` to close the window (see [Security Hardening](/deploy-security)). The tmpfile is removed after exec via a deferred cleanup. **SSH private keys are validated twice at save time** — first with Go's SSH parser, then with OpenSSH (`ssh-keygen -y -f `) when `ssh-keygen` is available — to catch keys that would save but later fail with OpenSSH diagnostics. **Passphrase-protected SSH keys are rejected at save time** — re-export your key without a passphrase, or use a dedicated deploy key.
### Host scope
Both `pat` and `ssh_key` require a **`host_scope`** — the exact ASCII `host` or `host:port` the credential is valid for. It is normalized to lowercase ASCII (via `idna.ToASCII`) and matched **exactly**. v1 has **no wildcards**, and the port is part of the key:
| Stored `host_scope` | `github.com` | `api.github.com` | `github.com:8443` |
|---------------------|:---:|:---:|:---:|
| `github.com` | ✓ | ✗ | ✗ |
If you run a self-hosted server on the scheme's default port (443 HTTPS, 22 SSH), omit the port; if on a non-default port, include it (e.g. `gitea.internal:8443`). When no typed PAT/SSH credential is selected, or the selected credential cannot match the resolved remote host, **adapter-managed remote git commands fail closed with a GoClaw diagnostic**. `git` is **not** allowed to fall through to an interactive username/password prompt in agent runtime.
### Env visibility: sensitive vs non-sensitive
Stored env entries now carry a `kind`. When the dashboard or admin API reads a credential back, the response masks values according to kind:
| `kind` | In API response |
|--------|-----------------|
| `sensitive` (default; legacy string maps decode here) | `value: null`, `masked: true` |
| `value` (explicitly non-sensitive, e.g. a region or profile name) | plain value returned, `masked: false` |
This lets operators see non-secret context (e.g. `AWS_DEFAULT_REGION=us-west-2`) in the UI while secrets stay masked. Secrets are still never returned except via the dedicated `env:reveal` endpoint.
### Migrating from legacy env credentials
There is no forced migration. A row with `credential_type IS NULL` or `= 'env'` keeps emitting its env vars exactly as before. To upgrade a git credential, create a matching **Agent Credential** (or, if a stable user ID is the intended boundary, an Advanced user override), enter the host scope, paste the secret, and save. Existing user overrides remain higher precedence than agent credentials, so you can migrate gradually and remove the user override when it is no longer needed.
### v1 limitations
- **One credential per `(agent, binary)` row**, plus a legacy one credential per `(user, binary)` override.
- **No wildcard hosts** — one credential per exact `host[:port]`; `*.github.com` is not supported.
- **No passphrase-protected SSH keys** — rejected at validation time.
- **No sandbox propagation** — the adapter mutates the forked child's environment, which is incompatible with the bind-mount Docker sandbox path. Credentialed exec runs on the host only in v1.
- **No host-key pinning** — SSH uses TOFU (`accept-new`); pre-seed `known_hosts`.
### Google Workspace CLI (gws)
GoClaw ships a `gws` preset for the Google Workspace CLI (`@googleworkspace/cli`).
**Availability.** The `gws` binary is preinstalled **only in the published `full` Docker image**. On `latest`/`base` images, install `@googleworkspace/cli` from the Packages page (requires a Node-enabled build, `ENABLE_NODE=true`; Node.js 18+).
**Credentials.** Create a SecureCLI credential from the `gws` preset and provide at least one auth source:
| Env var | Purpose |
|---------|---------|
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to exported `gws` credentials or an OAuth credentials JSON file |
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained Google OAuth access token (optional) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID for manual auth flows (optional) |
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret for manual auth flows (optional) |
**Blocked commands.** The preset blocks interactive and credential-exporting auth flows:
```
gws auth setup gws auth login gws auth export gws auth logout
```
Run those flows outside agent execution, then store the resulting token or credentials-file path in SecureCLI.
**Usage.** Default usage is read-oriented. Use `--params` for query parameters, `--json` for request bodies, and `--page-all` for paginated reads:
```sh
gws drive files list --params '{"pageSize": 10}'
gws gmail users messages list --params '{"userId": "me", "maxResults": 10}'
gws calendar events list --params '{"calendarId": "primary", "maxResults": 10}'
```
> **Write caution.** Write commands can modify Workspace data. Keep the default preset read-oriented and create a separate, reviewed SecureCLI config for any approved write workflow.
## Common Patterns
### Allow only one agent to use a sensitive CLI tool
1. Create the binary with `is_global = false`
2. Create a grant for the target agent
### Give all agents access but restrict args for one agent
1. Create the binary with `is_global = true`
2. Create a grant for the restricted agent with `deny_args` set to additional blocked patterns
### Temporarily disable an agent's access
Update the grant: `{"enabled": false}`. The binary remains accessible to other agents.
## Common Issues
| Problem | Solution |
|---------|----------|
| Agent cannot run a binary | Check `is_global` on the binary — if `false`, the agent needs an explicit grant |
| Grant overrides not applied | Verify the grant `enabled = true` and that override fields are non-null |
| `403` on grant endpoints | Requires admin role — check API key scopes |
| `git clone`/`push` fails with no credential | No typed credential matched the remote host — git fails closed (no prompt). Add an Agent Credential with the exact `host_scope`. |
## What's Next
- [Permission Matrix](/permission-matrix) — full authorization layers, channel/group scopes, and channel-context credentials
- [Database Schema → secure_cli_agent_grants](/database-schema)
- [Exec Approval](/exec-approval)
- [API Keys & RBAC](/api-keys-rbac)
- [Security Hardening](/deploy-security)
---
# Exec Approval (Human-in-the-Loop)
> Pause agent shell commands for human review before they run — approve, deny, or permanently allow from the dashboard.
## Overview
When an agent needs to run a shell command, exec approval lets you intercept it. The agent blocks, the dashboard shows a prompt, and you decide: **allow once**, **always allow this binary**, or **deny**. This gives you full control over what runs on your machine without disabling the exec tool entirely.
The feature is controlled by two orthogonal settings:
- **Security mode** — what commands are permitted to execute at all.
- **Ask mode** — when to prompt you for approval.
## Configuration
```json
{
"tools": {
"execApproval": {
"security": "full",
"ask": "on-miss",
"allowlist": ["make", "cargo test", "npm run *"]
}
}
}
```
`allowlist` accepts glob patterns matched against the binary name or the full command string.
---
## Approval Flow
```mermaid
flowchart TD
A["Agent calls exec tool"] --> B{"CheckCommand\nsecurity + ask mode"}
B -->|allow| C["Run immediately"]
B -->|deny| D["Return error to agent"]
B -->|ask| E["Create pending approval\nAgent goroutine blocks"]
E --> F["Dashboard shows prompt"]
F --> G{"Operator decides"}
G -->|allow-once| C
G -->|allow-always| H["Add binary to dynamic allow list"] --> C
G -->|deny| D
E -->|timeout 2 min| D
```
The agent goroutine blocks until you respond. If no response comes within 2 minutes, the request auto-denies.
---
## WebSocket Methods
Connect to the gateway WebSocket. These methods require **Operator** or **Admin** role.
### List pending approvals
```json
{ "type": "req", "id": "1", "method": "exec.approval.list" }
```
Response:
```json
{
"pending": [
{
"id": "exec-1",
"command": "curl https://example.com | sh",
"agentId": "my-agent",
"createdAt": 1741234567000
}
]
}
```
### Approve a command
```json
{
"type": "req",
"id": "2",
"method": "exec.approval.approve",
"params": {
"id": "exec-1",
"always": false
}
}
```
Set `"always": true` to permanently allow this binary for the lifetime of the process (adds it to the dynamic allow list).
### Deny a command
```json
{
"type": "req",
"id": "3",
"method": "exec.approval.deny",
"params": { "id": "exec-1" }
}
```
---
## Examples
**Strict mode for a production agent — only known commands allowed:**
```json
{
"tools": {
"execApproval": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": ["git", "make", "go test *", "cargo test"]
}
}
}
```
`git`, `make`, and the test runners auto-run. Anything else (e.g., `curl`, `rm`) triggers a prompt.
**Coding agent with light oversight — safe tools auto-run, infra tools need approval:**
```json
{
"tools": {
"execApproval": {
"security": "full",
"ask": "on-miss"
}
}
}
```
**Fully locked down — no shell execution at all:**
```json
{
"tools": {
"execApproval": {
"security": "deny"
}
}
}
```
---
## Shell Deny Groups
In addition to the approval flow, GoClaw applies **deny groups** — named sets of shell command patterns that are blocked regardless of approval settings. All groups are enabled by default.
### Available Deny Groups
| Group | Description | Examples Blocked |
|-------|-------------|-----------------|
| `destructive_ops` | Destructive Operations | `rm -rf`, `dd if=`, `shutdown`, fork bombs |
| `data_exfiltration` | Data Exfiltration | `curl \| sh`, `wget --post-data`, DNS lookups via dig/nslookup |
| `reverse_shell` | Reverse Shell | `nc`, `socat`, `python -c '...socket...'`, `mkfifo` |
| `code_injection` | Code Injection & Eval | `eval $()`, `base64 -d \| sh` |
| `privilege_escalation` | Privilege Escalation | `sudo`, `su`, `mount`, `nsenter`, `pkexec` |
| `dangerous_paths` | Dangerous Path Operations | `chmod +x /tmp/...`, `chown ... /` |
| `env_injection` | Environment Variable Injection | `LD_PRELOAD=`, `DYLD_INSERT_LIBRARIES=`, `BASH_ENV=` |
| `container_escape` | Container Escape | `/var/run/docker.sock`, `/proc/sys/kernel/`, `/sys/kernel/` |
| `crypto_mining` | Crypto Mining | `xmrig`, `cpuminer`, `stratum+tcp://` |
| `filter_bypass` | Filter Bypass (CVE mitigations) | `sed .../e`, `sort --compress-program`, `git --upload-pack=` |
| `network_recon` | Network Reconnaissance & Tunneling | `nmap`, `ssh user@host`, `ngrok`, `chisel` |
| `package_install` | Package Installation | `pip install`, `npm install`, `apk add` |
| `persistence` | Persistence Mechanisms | `crontab`, writing to `~/.bashrc` or `~/.profile` |
| `process_control` | Process Manipulation | `kill -9`, `killall`, `pkill` |
| `env_dump` | Environment Variable Dumping | `printenv`, `env \| ...`, reading `GOCLAW_` secrets |
### Per-Agent Deny Group Overrides
Each agent can selectively enable or disable specific deny groups via `shell_deny_groups` in its config. This is a `map[string]bool` where `true` means deny (block) and `false` means allow (unblock).
All groups default to `true` (denied). Explicitly set a group to `false` to allow those commands for a specific agent.
**Example: allow package installs but keep everything else blocked**
```json
{
"agents": {
"my-agent": {
"shell_deny_groups": {
"package_install": false
}
}
}
}
```
**Example: allow SSH/tunneling for a DevOps agent, but block crypto mining**
```json
{
"agents": {
"devops-agent": {
"shell_deny_groups": {
"network_recon": false,
"crypto_mining": true
}
}
}
}
```
Deny groups and the exec approval flow operate independently — a command can pass the deny-group check but still be held for human approval based on your `ask` mode setting.
### Live-reload persistence
Global deny-group changes apply immediately without a restart. The reload clones the config snapshot before applying it, so **disables persist correctly** across reload — a group you turned off stays off until you turn it back on. The same reload also refreshes provider-level shell-deny policies (Claude CLI / ACP), not just the global exec tool.
### venv Python exemption
GoClaw blocks shell access to its internal `.goclaw/` data directory. The **GoClaw-managed Python interpreter is exempt**: invoking it by its absolute path (`/.goclaw/venv/bin/python3`) is allowed. GoClaw resolves that path (following symlinks) once at startup and exempts the resolved interpreter directory; if no venv is present it falls back silently. This is the only path under `.goclaw/` an agent's shell commands can reach.
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| No approval prompt appears | `ask` is `"off"` (default) | Set `ask` to `"on-miss"` or `"always"` |
| Command denied with no prompt | `security = "allowlist"`, command not in allowlist, `ask = "off"` | Add to `allowlist` or change `ask` to `"on-miss"` |
| Approval request timed out | Operator didn't respond within 2 minutes | Command is auto-denied; agent may retry or ask you to re-run |
| `exec approval is not enabled` | No `execApproval` block in config, method called anyway | Add `tools.execApproval` section to config |
| `id is required` error | Calling approve/deny without passing the approval `id` | Include `"id": "exec-N"` in params (from the list response) |
---
## What's Next
- [Sandbox](/sandbox) — run exec commands inside an isolated Docker container
- [Custom Tools](/custom-tools) — define tools backed by shell commands
- [Security Hardening](/deploy-security) — full five-layer security overview
---
# Context Pruning
> Automatically trim old tool results to keep agent context within token limits.
## Overview
As agents run long tasks, tool results accumulate in the conversation history. Large tool outputs — file reads, API responses, search results — can consume most of the context window, leaving little room for new reasoning.
**Context pruning** trims these old tool results in-memory before each LLM request, without touching the persisted session history. It uses a two-pass strategy:
1. **Soft trim** — truncate oversized tool results to head + tail, dropping the middle.
2. **Hard clear** — if the context is still too full, replace entire tool results with a short placeholder.
Context pruning is distinct from [session compaction](../core-concepts/sessions-and-history.md). Compaction permanently summarizes and truncates conversation history. Pruning is non-destructive: the original tool results remain in the session store and are never modified — only the message slice sent to the LLM is trimmed.
## Soft Trim
Soft trim keeps the beginning and end of a long tool result, dropping the middle.
A tool result is eligible for soft trim when its character count exceeds `softTrim.maxChars`.
The trimmed result looks like:
```
...
[Tool result trimmed: kept first 3000 chars and last 3000 chars of 38400 chars.]
```
**Media tool protection:** Results from `read_image`, `read_document`, `read_audio`, and `read_video` receive a higher soft trim budget (headChars=4000, tailChars=4000) because their content is an irreplaceable description generated by a dedicated vision/audio provider. Re-generating it would require another LLM call. Media tool results are also **exempt from hard clear** — they are never replaced with the placeholder.
The agent retains enough context to understand what the tool returned without consuming the full output.
---
## Hard Clear
Hard clear replaces the entire content of old tool results with a short placeholder string. It runs as a second pass only if the context ratio is still too high after soft trim.
Hard clear processes prunable tool results one by one, recalculating the ratio after each replacement, and stops as soon as the ratio drops below `hardClearRatio`.
A hard-cleared tool result becomes:
```
[Old tool result content cleared]
```
This placeholder is configurable. Hard clear can also be disabled entirely.
---
## Configuration
Context pruning runs with `cache-ttl` mode **by default** — no config needed to activate it. To disable pruning entirely, set `mode: "off"`.
```json
{
"contextPruning": {
"mode": "off"
}
}
```
All other fields have sensible defaults and are optional.
### Full configuration reference
```json
{
"contextPruning": {
"mode": "cache-ttl",
"keepLastAssistants": 3,
"softTrimRatio": 0.25,
"hardClearRatio": 0.5,
"minPrunableToolChars": 50000,
"softTrim": {
"maxChars": 6000,
"headChars": 3000,
"tailChars": 3000
},
"hardClear": {
"enabled": true,
"placeholder": "[Old tool result content cleared]"
}
}
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `mode` | `"cache-ttl"` *(enabled by default)* | Set to `"off"` to disable pruning. Omit or leave empty to keep the default `cache-ttl` mode. |
| `keepLastAssistants` | `3` | Number of recent assistant turns to protect from pruning. |
| `softTrimRatio` | `0.25` | Trigger soft trim when context fills this fraction of the context window. |
| `hardClearRatio` | `0.5` | Trigger hard clear when context fills this fraction after soft trim. |
| `minPrunableToolChars` | `50000` | Minimum total chars in prunable tool results before hard clear runs. Prevents aggressive clearing on small contexts. |
| `softTrim.maxChars` | `6000` | Tool results longer than this are eligible for soft trim. |
| `softTrim.headChars` | `3000` | Characters to keep from the start of a trimmed tool result. |
| `softTrim.tailChars` | `3000` | Characters to keep from the end of a trimmed tool result. |
| `hardClear.enabled` | `true` | Set to `false` to disable hard clear entirely (soft trim only). |
| `hardClear.placeholder` | `"[Old tool result content cleared]"` | Replacement text for hard-cleared tool results. |
---
## Configuration Examples
### Disable pruning
Pruning is on by default. To turn it off:
```json
{
"contextPruning": {
"mode": "off"
}
}
```
### Aggressive — for long tool-heavy workflows
Trigger earlier and keep less context per tool result:
```json
{
"contextPruning": {
"mode": "cache-ttl",
"softTrimRatio": 0.2,
"hardClearRatio": 0.4,
"softTrim": {
"maxChars": 2000,
"headChars": 800,
"tailChars": 800
}
}
}
```
### Soft trim only — disable hard clear
```json
{
"contextPruning": {
"mode": "cache-ttl",
"hardClear": {
"enabled": false
}
}
}
```
### Custom placeholder
```json
{
"contextPruning": {
"mode": "cache-ttl",
"hardClear": {
"placeholder": "[Tool output removed to save context]"
}
}
}
```
---
## Pruning and the Consolidation Pipeline
Context pruning and memory consolidation serve complementary roles — pruning manages live context during a session; consolidation manages long-term recall across sessions.
```
Within a session: pruning trims tool results → keeps LLM context lean
On session.completed: episodic_worker summarizes → L1 episodic memory
After ≥5 episodes: dreaming_worker promotes → L0 long-term memory
```
**Key distinction**: pruning never touches the persisted session store. Once a session completes, the consolidation pipeline (not pruning) takes over and determines what is worth keeping long-term. This means:
- Pruned tool results are still visible to `episodic_worker` via the session store when it reads messages for summarization.
- Content that was hard-cleared from live context is still summarized into episodic memory on session completion — nothing is permanently lost by pruning.
- For content that has been promoted to episodic or long-term memory by `dreaming_worker`, the **auto-injector** re-surfaces it as concise L0 abstracts at the start of the next turn. This replaces the need to keep bulky tool results alive in context.
### Practical consequence
Once the consolidation pipeline has promoted a body of knowledge to L0 (via dreaming) or L1 (via episodic), you can allow pruning to be more aggressive for that agent. The agent will not lose information — it will be re-injected from memory rather than carried forward in raw session history.
---
## Impact on Agent Behavior
- **No session data is modified.** Pruning only affects the message slice passed to the LLM. The original tool results remain in the session store.
- **Recent context is always preserved.** The last `keepLastAssistants` assistant turns and their associated tool results are never touched.
- **Soft-trimmed results still provide signal.** The agent sees the beginning and end of long outputs, which usually contain the most relevant information (headers, summaries, final lines).
- **Hard-cleared results may cause repeated tool calls.** If an agent can no longer see a tool result, it may re-run the tool to recover the information. This is expected behavior.
- **Context window size matters.** Pruning thresholds are ratios of the actual model context window. Agents configured with larger context windows will prune less aggressively.
---
## Common Issues
**Pruning never triggers**
Pruning is enabled by default. If it appears inactive, confirm that `mode` is not explicitly set to `"off"` in the agent config. Also confirm that `contextWindow` is set on the agent — pruning needs a token count to calculate ratios. Finally, verify the context ratio is actually reaching `softTrimRatio` (0.25 by default).
**Agent re-runs tools unexpectedly**
Hard clear removes tool result content entirely. If the agent needs that content, it will call the tool again. Lower `hardClearRatio` or increase `minPrunableToolChars` to delay hard clear, or disable it with `hardClear.enabled: false`.
**Trimmed results cut off important content**
Increase `softTrim.headChars` and `softTrim.tailChars`, or raise `softTrim.maxChars` so fewer results are eligible for trimming.
**Context still overflows despite pruning being enabled**
Pruning only acts on tool results. If long user messages or system prompt components dominate the context, pruning will not help. Consider [session compaction](../core-concepts/sessions-and-history.md) or reduce the system prompt size.
---
## Pipeline Improvements
### Tiktoken BPE Token Counting
GoClaw now uses the tiktoken BPE tokenizer for accurate token counting instead of the legacy `chars / 4` heuristic. This matters especially for CJK content (Vietnamese and Chinese characters), where the heuristic significantly underestimates token usage. With tiktoken enabled, all pruning ratios are calculated against actual token counts rather than character estimates.
### Pass 0 Per-Result Guard
Before normal pruning passes begin, any single tool result that exceeds **30% of the context window** is force-trimmed. This catches outlier outputs (e.g., a massive file read or API response) even when the overall context ratio is still below `softTrimRatio`. The trimmed result keeps a 70/30 head/tail split.
### Media Tool Protection
Results from `read_image`, `read_document`, `read_audio`, and `read_video` are handled specially:
- They receive a higher soft trim budget: **headChars=4000, tailChars=4000** (vs. the standard 3000/3000).
- They are **exempt from hard clear** — media descriptions are generated by dedicated vision/audio providers (Gemini, Anthropic) and cannot be regenerated without another LLM call.
### MediaRefs Compaction
During history compaction, up to **30 most recent `MediaRefs`** are preserved. This ensures the agent can still reference previously shared images and documents after compaction without losing track of media context.
### Structured Compaction Summary
When context is compacted, the summary now preserves key identifiers — agent IDs, task IDs, and session keys — in a structured format. This ensures that agents can continue referencing their active tasks and sessions after compaction without losing critical tracking context.
### Tool Output Capping at Source
Tool output is now capped at the source before being added to context. Rather than waiting for the pruning pipeline to trim oversized results after the fact, GoClaw limits tool output size at ingestion time. This reduces unnecessary memory pressure and makes the pruning pipeline more predictable.
### Dynamic Compaction Summary Budget
When session compaction runs, the output-token budget for the summary is no longer a static cap. It is now computed dynamically:
```
max_tokens = clamp(input_tokens / 25, 1024, 8192)
```
Short histories get a smaller budget (floor: 1024 tokens) and long histories get a larger one (cap: 8192 tokens). This replaces any previously documented static 4096-token cap.
### Tool-Schema Tokens in OverheadTokens
`OverheadTokens` — the token count that ContextStage subtracts from the usable window before pruning — now includes the tokens consumed by all registered tool schemas, in addition to the system prompt. Previously only system-prompt tokens were counted. This means agents with many or large tools will see a higher overhead value and pruning will trigger slightly earlier.
### Compaction Overflow Recovery
When the context remains over budget even after a compaction sweep (for example, the system prompt and tool schemas alone nearly fill the window), GoClaw performs a secondary recovery sweep before surfacing an error. This overflow recovery path (PR #958) caps retries at one attempt and returns a `context overflow after compaction` error only when the second sweep also fails. In practice this prevents hard failures for agents with large tool schemas or system prompts.
### Pending-Message Preservation
Compaction can trigger **mid-loop**, between an assistant message that requests tool calls and the `tool` results that answer them. GoClaw now preserves the in-flight assistant `tool_calls` message across a mid-loop compaction sweep, so the next provider request never sends `tool` result messages without their preceding `tool_calls` message. Without this, the provider would reject the request with an **HTTP 400** (orphaned tool results). If you previously saw intermittent 400 errors on long tool-heavy turns, this is the fix.
---
## What's Next
- [Sessions & History](../core-concepts/sessions-and-history.md) — session compaction, history limits
- [Memory System](../core-concepts/memory-system.md) — 3-tier memory architecture and consolidation pipeline
- [Configuration Reference](/config-reference) — full agent config reference
---
# Channel Instances
> Run multiple accounts per channel type — each with its own credentials, agent binding, and writer permissions.
## Overview
A **channel instance** is a named connection between one messaging account and one agent. It stores the account credentials (encrypted at rest), an optional channel-specific config, and the ID of the agent that owns it.
Because instances are stored in the database and identified by UUID, you can:
- Connect multiple Telegram bots to different agents on the same server
- Add a second Slack workspace without touching the first
- Disable a channel without deleting it or its credentials
- Rotate credentials with a single `PUT` call
Every instance belongs to exactly one agent. When a message arrives on that channel account, GoClaw routes it to the bound agent.
```mermaid
graph LR
TelegramBot1["Telegram bot @sales"] -->|channel_instance| AgentSales["Agent: sales"]
TelegramBot2["Telegram bot @support"] -->|channel_instance| AgentSupport["Agent: support"]
SlackWS["Slack workspace A"] -->|channel_instance| AgentOps["Agent: ops"]
```
### Default instances
Instances whose `name` equals a bare channel type (`telegram`, `discord`, `feishu`, `zalo_oa`, `whatsapp`) or ends with `/default` are **default** (seeded) instances. Default instances **cannot be deleted** via the API — they are managed by GoClaw at startup.
## Instance object
All API responses return an instance object with credentials masked:
```json
{
"id": "3f2a1b4c-0000-0000-0000-000000000001",
"name": "telegram/sales-bot",
"display_name": "Sales Bot",
"channel_type": "telegram",
"agent_id": "a1b2c3d4-...",
"credentials": { "token": "***" },
"has_credentials": true,
"config": {},
"enabled": true,
"is_default": false,
"created_by": "admin",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z"
}
```
| Field | Type | Notes |
|---|---|---|
| `id` | UUID | Auto-generated |
| `name` | string | Unique identifier slug (e.g. `telegram/sales-bot`) |
| `display_name` | string | Human-readable label (optional) |
| `channel_type` | string | One of the supported types above |
| `agent_id` | UUID | Agent that owns this instance |
| `credentials` | object | Credential keys are shown; values are always `"***"` |
| `has_credentials` | bool | `true` if credentials are stored |
| `config` | object | Channel-specific config (optional) |
| `enabled` | bool | `false` disables the instance without deleting it |
| `is_default` | bool | `true` for seeded instances — cannot be deleted |
---
## Instance `config` reference
The `config` object holds channel-specific settings. Beyond the per-channel keys documented on each channel page, two cross-channel concerns are worth calling out here.
### Delivery overrides (`chat_behavior`, `block_reply`)
Channel instances can override the workspace-level [human-like delivery](/channels-overview#human-like-delivery) behavior:
| Key | Type | Notes |
|---|---|---|
| `chat_behavior` | object | Override `gateway.chat_behavior` for this instance (omit = inherit). Set only the fields you want to change. |
| `block_reply` | bool | **Legacy.** Read as the inherited default for `intermediate_replies.enabled` when the newer field is unset. |
Resolution order is **Channel > Agent > Workspace**:
1. **Workspace** — `gateway.chat_behavior` (the base).
2. **Agent** — `agents.other_config.delivery_behavior` overrides the workspace base.
3. **Channel** — the instance's `config.chat_behavior` has the final say.
Each level only overrides the fields it sets, so you can tune one knob per channel and inherit the rest. Only channels that implement the `ChatBehaviorChannel` interface honor these overrides (Bitrix24, Discord, Feishu/Lark, Pancake, Slack, Telegram, WhatsApp, Zalo OA, Zalo Personal).
### Passive channel memory (`passive_memory`)
An instance can opt into [passive channel memory extraction](/memory-system#passive-channel-memory-extraction) by adding a `passive_memory` block to its `config`:
```json
{
"config": {
"passive_memory": {
"enabled": true,
"review_mode": true,
"interval_minutes": 360,
"min_messages": 5
}
}
}
```
Disabled by default, group-only in v1, and review-gated. See [Memory System › Passive Channel Memory Extraction](/memory-system#passive-channel-memory-extraction) for the full field table and behavior.
---
## REST API
All endpoints require `Authorization: Bearer `.
### List instances
```bash
GET /v1/channels/instances
```
Query parameters: `search`, `limit` (max 200, default 50), `offset`.
```bash
curl http://localhost:8080/v1/channels/instances \
-H "Authorization: Bearer $GOCLAW_TOKEN"
```
Response:
```json
{
"instances": [...],
"total": 4,
"limit": 50,
"offset": 0
}
```
---
### Get instance
```bash
GET /v1/channels/instances/{id}
```
```bash
curl http://localhost:8080/v1/channels/instances/3f2a1b4c-... \
-H "Authorization: Bearer $GOCLAW_TOKEN"
```
---
### Create instance
```bash
POST /v1/channels/instances
```
Required fields: `name`, `channel_type`, `agent_id`.
```bash
curl -X POST http://localhost:8080/v1/channels/instances \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "telegram/sales-bot",
"display_name": "Sales Bot",
"channel_type": "telegram",
"agent_id": "a1b2c3d4-...",
"credentials": {
"token": "7123456789:AAF..."
},
"enabled": true
}'
```
Returns `201 Created` with the new instance object (credentials masked).
---
### Update instance
```bash
PUT /v1/channels/instances/{id}
```
Send only the fields you want to change. Credential updates are **merged** into existing credentials — partial updates do not wipe other credential keys.
```bash
# Rotate just the bot token, keep other credentials intact
curl -X PUT http://localhost:8080/v1/channels/instances/3f2a1b4c-... \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"credentials": { "token": "7999999999:BBG..." }
}'
```
```bash
# Disable an instance without deleting it
curl -X PUT http://localhost:8080/v1/channels/instances/3f2a1b4c-... \
-H "Authorization: Bearer $GOCLAW_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'
```
Returns `{ "status": "updated" }`.
---
### Delete instance
```bash
DELETE /v1/channels/instances/{id}
```
Returns `403 Forbidden` if the instance is a default (seeded) instance.
```bash
curl -X DELETE http://localhost:8080/v1/channels/instances/3f2a1b4c-... \
-H "Authorization: Bearer $GOCLAW_TOKEN"
```
---
## Channel Health
Each channel instance exposes a runtime health snapshot. GoClaw tracks the current lifecycle state, failure classification, failure counters, and an operator remediation hint.
### Health states
| State | Meaning |
|---|---|
| `registered` | Instance created but not yet started |
| `starting` | Channel is initializing (connecting to upstream) |
| `healthy` | Channel is running and accepting messages |
| `degraded` | Channel is running but experiencing issues |
| `failed` | Channel failed to start or crashed |
| `stopped` | Channel was intentionally stopped |
### Failure classification
When a channel enters `failed` or `degraded` state, GoClaw classifies the error into one of four kinds:
| Kind | Examples | Retryable |
|---|---|---|
| `auth` | 401 Unauthorized, invalid token | No |
| `config` | Missing credentials, invalid proxy URL, agent not found | No |
| `network` | Timeout, connection refused, DNS failure, EOF | Yes |
| `unknown` | Unexpected errors | Yes |
### Remediation hints
Each failed channel includes a `remediation` object with a `code`, `headline`, and `hint` pointing to the relevant UI surface (`credentials`, `advanced`, `reauth`, or `details`). For example, a Zalo Personal auth failure suggests re-opening the sign-in flow rather than checking credentials.
Health data is available in the channel instance detail view in the Web UI and via the `GET /v1/channels/instances/{id}` endpoint.
---
## Group file writers
Each channel instance exposes writer-management endpoints that delegate to its bound agent. Writers control who can upload files through the group file feature.
```bash
# List writer groups for a channel instance
GET /v1/channels/instances/{id}/writers/groups
# List writers in a group
GET /v1/channels/instances/{id}/writers?group_id=
# Add a writer
POST /v1/channels/instances/{id}/writers
{
"group_id": "...",
"user_id": "123456789",
"display_name": "Alice",
"username": "alice"
}
# Remove a writer
DELETE /v1/channels/instances/{id}/writers/{userId}?group_id=
```
---
## Credentials security
- Credentials are **AES-encrypted** before storage in PostgreSQL.
- API responses **never return plaintext credentials** — all values are replaced with `"***"`.
- `has_credentials: true` in the response confirms credentials are stored.
- Partial credential updates are safe: GoClaw merges the new keys into the existing (decrypted) object before re-encrypting.
---
## Common issues
| Issue | Cause | Fix |
|---|---|---|
| `403` on delete | Instance is a default/seeded instance | Default instances cannot be deleted; disable them with `enabled: false` instead |
| `400 invalid channel_type` | Typo or unsupported type | Use one of: `telegram`, `discord`, `slack`, `whatsapp`, `zalo_oa`, `zalo_personal`, `feishu`, `bitrix24`, `pancake`, `facebook` |
| Messages not routing to agent | Instance is disabled or `agent_id` is wrong | Verify `enabled: true` and the correct `agent_id` |
| Credentials not persisted | `GOCLAW_ENCRYPTION_KEY` not set | Set the encryption key env var; credentials require it |
| Cache stale after update | In-memory cache not yet refreshed | GoClaw broadcasts a cache-invalidate event on every write; cache refreshes within seconds |
---
## What's Next
- [Channel Overview](/channels-overview)
- [Multi-Channel Setup](/recipe-multi-channel)
- [Multi-Tenancy](/multi-tenancy)
---
# Webhooks
> Trigger agents or deliver channel messages from external systems via HMAC-authenticated HTTP endpoints.
## Overview
GoClaw webhooks expose two HTTP entry points that let external systems drive the gateway without speaking the WebSocket RPC protocol:
| Kind | Endpoint | Purpose | Editions |
|------|----------|---------|----------|
| `llm` | `POST /v1/webhooks/llm` | Invoke an agent with a user prompt (sync or async) | Standard + Lite |
| `message` | `POST /v1/webhooks/message` | Send a message to a user on a connected channel | Standard only |
Each webhook is a tenant-scoped registry row. An admin creates the row via the CRUD API and receives a one-time bearer secret plus an HMAC signing key; callers then authenticate inbound requests with either credential.
> **Encryption key required.** The webhook subsystem only mounts when `GOCLAW_ENCRYPTION_KEY` is set. Without it, all `/v1/webhooks/*` routes return `404` and the gateway logs `webhook subsystem disabled`. See [Security Hardening](/deploy-security) and [Environment Variables](/env-vars).
## Admin CRUD
All admin endpoints require a tenant-admin gateway token (`Authorization: Bearer `).
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v1/webhooks` | Create a webhook; returns raw secret and HMAC key (once) |
| `GET` | `/v1/webhooks` | List webhooks for the tenant. Optional `?agent_id=` filter |
| `GET` | `/v1/webhooks/{id}` | Fetch a single webhook (no secret) |
| `PATCH` | `/v1/webhooks/{id}` | Partial update; `kind` is immutable |
| `POST` | `/v1/webhooks/{id}/rotate` | Issue a new secret (old secret invalidated immediately) |
| `DELETE` | `/v1/webhooks/{id}` | Revoke; subsequent inbound calls return `401` |
See [REST API → Webhooks](/rest-api) and the [Endpoint Catalog](/api-endpoints-catalog) for full request/response shapes.
### Create
```bash
curl -X POST https://gw.example.com/v1/webhooks \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"name": "billing-sync",
"kind": "llm",
"agent_id": "9c5a...e4",
"require_hmac": true,
"rate_limit_per_min": 60,
"ip_allowlist": ["10.0.0.0/8"]
}'
```
Response (201 Created):
```json
{
"id": "f5a1...",
"name": "billing-sync",
"secret_prefix": "wh_ABCD",
"secret": "wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH",
"hmac_signing_key": "a3f4...hex64chars",
"kind": "llm",
"rate_limit_per_min": 60,
"require_hmac": true,
"localhost_only": false,
"ip_allowlist": ["10.0.0.0/8"],
"created_at": "2026-05-21T12:00:00Z"
}
```
> `secret` and `hmac_signing_key` are returned **once** — on create and on rotate. Store them in your secret manager immediately; they cannot be retrieved again.
### Create payload fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | yes | Max 100 chars |
| `kind` | string | yes | `"llm"` or `"message"` |
| `agent_id` | UUID | for `llm` kind | Agent to invoke |
| `channel_id` | UUID | optional | Pin a `message` webhook to one channel instance |
| `require_hmac` | bool | no | Force HMAC-only auth (disables bearer) |
| `localhost_only` | bool | no | Restrict callers to 127.0.0.1/::1. Forced `true` on Lite |
| `rate_limit_per_min` | int | no | Per-webhook cap (0 = use tenant default) |
| `ip_allowlist` | []string | no | IPs or CIDR ranges. Empty allows any source |
## Authentication
The `require_hmac` flag on the webhook row decides which modes are accepted.
### Bearer auth
```
Authorization: Bearer wh_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGH
```
The gateway hashes the token with SHA-256, compares it to `secret_hash` via constant-time comparison, then decrypts the stored secret material with `GOCLAW_ENCRYPTION_KEY`. Bearer auth is **rejected** when `require_hmac=true`.
### HMAC auth (recommended)
Send three headers:
```
X-Webhook-Id:
X-GoClaw-Signature: t=,v1=
Content-Type: application/json
```
Signing algorithm:
```
signing_key = hex.Decode(hmac_signing_key) // hex_64 → 32 raw bytes
payload = "{unix_ts}.{request_body_bytes}"
signature = HMAC_SHA256(signing_key, payload)
header = "t={unix_ts},v1={hex(signature)}"
```
`hmac_signing_key` equals `hex(SHA-256(raw_secret))`. The gateway never stores the raw secret — only its hash and an AES-256-GCM ciphertext used by background workers to recompute HMAC at delivery time.
**Timestamp skew.** Requests with `|now - t| > 300` seconds are rejected. Synchronize clocks via NTP.
**Replay protection.** After a valid signature is accepted, the gateway records `sha256(tenant_id|signature_hex)` in an in-memory nonce cache (TTL 320s). Replays return `401` with the audit event `security.webhook.hmac_replay`. The cache is per-process — a multi-node deployment can theoretically accept a replay on a sibling node.
**IP allowlist.** When `ip_allowlist` is non-empty, each entry is matched against the request's `RemoteAddr` after authentication. CIDR ranges and bare addresses are supported. `X-Forwarded-For` is **not** trusted.
## POST /v1/webhooks/llm
Triggers an agent with an input prompt. Available in all editions (Standard + Lite).
```bash
curl -X POST https://gw.example.com/v1/webhooks/llm \
-H "Authorization: Bearer wh_..." \
-H "Content-Type: application/json" \
-d '{
"input": "Summarize the latest metrics",
"session_key": "user-123-session",
"mode": "sync"
}'
```
Request fields:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `input` | string or array | yes | Plain string, or `[{role, content}]` array |
| `session_key` | string | no | Stable key for multi-turn conversation continuity |
| `user_id` | string | no | External user identifier for scoping |
| `model` | string | no | Per-request model override |
| `mode` | string | no | `"sync"` (default) or `"async"` |
| `callback_url` | string | required when async | HTTPS URL; validated against the SSRF policy |
| `metadata` | object | no | Echoed back to the async callback (max 8 KB) |
**Sync mode** times out at 30 seconds and returns the full agent output:
```json
{
"call_id": "",
"agent_id": "",
"output": "Here are the metrics: ...",
"usage": {"prompt_tokens": 150, "completion_tokens": 200, "total_tokens": 350},
"finish_reason": "stop"
}
```
**Async mode** returns `202 Accepted` with `{"call_id": "...", "status": "queued"}`; the result lands on `callback_url` via outbound delivery (see [Outbound callbacks](#outbound-callbacks)).
Error codes:
| Status | Code | When |
|--------|------|------|
| 400 | `invalid_request` | Missing `input`, bad `mode`, missing `callback_url` for async |
| 401 | — | Bearer invalid, HMAC mismatch, revoked, HMAC replay |
| 403 | `unauthorized` | `localhost_only` violated, IP denied, kind mismatch, tenant mismatch |
| 404 | `not_found` | Agent not found |
| 429 | — | Rate limit exceeded (`Retry-After: 60`) |
| 503 | — | Webhook processing lane at capacity |
| 504 | — | LLM timeout (sync only) |
## POST /v1/webhooks/message
Sends a message to a user on a connected channel. **Standard edition only.**
```json
{
"channel_name": "telegram-prod",
"chat_id": "123456789",
"content": "Hello from the integration!",
"media_url": "https://example.com/image.jpg",
"media_caption": "Optional caption",
"fallback_to_text": false
}
```
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `channel_name` | string | yes (unless the webhook has a bound `channel_id`) | Channel instance name |
| `chat_id` | string | yes | Channel-specific recipient ID |
| `content` | string | yes (unless `media_url`) | Text body; max 16 KB |
| `media_url` | string | no | HTTPS URL. SSRF-guarded + HEAD-probed before fetch |
| `media_caption` | string | no | Caption for media |
| `fallback_to_text` | bool | no | If true, send text-only when channel cannot handle media |
Response:
```json
{
"call_id": "",
"status": "sent",
"channel_name": "telegram-prod",
"chat_id": "123456789",
"warning": ""
}
```
`warning` is set to `"media_not_supported_fallback_text"` when `fallback_to_text=true` and the channel dropped the media. Channels that cannot deliver media (e.g., `zalo_oa`) return `501 Not Implemented` unless `fallback_to_text=true`.
## Idempotency
All webhook endpoints accept an `Idempotency-Key` header (max 255 chars).
- First request with a key: processed normally.
- Same key + identical body: returns the cached response with `200 OK`.
- Same key + different body: returns `409 Conflict` (`webhook.idempotency_conflict`).
- Keys expire after 24 hours (`webhook_calls` table TTL).
Use a UUID or a hash of the request payload. Re-send the **exact same body** on retry.
## Outbound callbacks
Async LLM calls deliver results to `callback_url` via `HTTP POST`. Delivery is **at-least-once** — receivers must be idempotent.
Every attempt carries these headers:
```
X-Webhook-Delivery-Id: ; stable across retries — dedupe on this
X-Webhook-Signature: t=,v1=; same HMAC scheme as inbound
Content-Type: application/json
User-Agent: goclaw-webhook/1
```
Payload:
```json
{
"call_id": "",
"delivery_id": "",
"agent_id": "",
"status": "done",
"output": "Agent response text...",
"usage": {"prompt_tokens": 150, "completion_tokens": 200, "total_tokens": 350},
"metadata": {},
"error": ""
}
```
`status` is `"done"` on success or `"failed"` on agent error (with `error` populated).
Retry schedule (±10% jitter):
| Attempt | Delay |
|---------|-------|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
After 5 failures the row moves to `status=dead`. A `429 Retry-After` from the receiver is respected (capped at 6 hours). `4xx` responses other than `429` are treated as permanent failures. Any `2xx` marks the delivery as done.
## Verifying signatures
### Go
```go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
)
func verify(body []byte, sigHeader, hmacSigningKey string) error {
var ts int64
var sigHex string
for _, part := range strings.Split(sigHeader, ",") {
switch {
case strings.HasPrefix(part, "t="):
fmt.Sscanf(part[2:], "%d", &ts)
case strings.HasPrefix(part, "v1="):
sigHex = part[3:]
}
}
if abs(time.Now().Unix()-ts) > 300 {
return fmt.Errorf("timestamp skew")
}
key, err := hex.DecodeString(hmacSigningKey)
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%d.", ts)
mac.Write(body)
expected := mac.Sum(nil)
received, err := hex.DecodeString(sigHex)
if err != nil || !hmac.Equal(expected, received) {
return fmt.Errorf("signature mismatch")
}
return nil
}
```
### Node.js
```js
const crypto = require('crypto');
function signWebhookRequest(body, hmacSigningKeyHex) {
const ts = Math.floor(Date.now() / 1000);
const key = Buffer.from(hmacSigningKeyHex, 'hex');
const payload = Buffer.concat([Buffer.from(`${ts}.`), Buffer.from(body)]);
const sig = crypto.createHmac('sha256', key).update(payload).digest('hex');
return { ts, header: `t=${ts},v1=${sig}` };
}
const body = JSON.stringify({ input: 'hello', mode: 'sync' });
const { header } = signWebhookRequest(body, process.env.WEBHOOK_HMAC_KEY);
await fetch('https://gw.example.com/v1/webhooks/llm', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Id': process.env.WEBHOOK_ID,
'X-GoClaw-Signature': header,
},
body,
});
```
### Python
```python
import hashlib, hmac, json, os, time, requests
def sign(body: bytes, hmac_signing_key_hex: str) -> str:
ts = int(time.time())
key = bytes.fromhex(hmac_signing_key_hex)
payload = f"{ts}.".encode() + body
sig = hmac.new(key, payload, hashlib.sha256).hexdigest()
return f"t={ts},v1={sig}"
body = json.dumps({"input": "hello", "mode": "sync"}).encode()
requests.post(
"https://gw.example.com/v1/webhooks/llm",
headers={
"Content-Type": "application/json",
"X-Webhook-Id": os.environ["WEBHOOK_ID"],
"X-GoClaw-Signature": sign(body, os.environ["WEBHOOK_HMAC_KEY"]),
},
data=body,
)
```
## Rate limits
Two tiers must both pass:
| Tier | Cap | Notes |
|------|-----|-------|
| Per-webhook | `rate_limit_per_min` field (0 = disabled) | Set on the webhook row |
| Per-tenant | Platform default | Shared across all webhooks for one tenant |
A reject from either tier returns `429 Too Many Requests` with `Retry-After: 60`.
## Edition differences
| Feature | Standard | Lite |
|---------|----------|------|
| `/v1/webhooks/llm` | Available | Available (`localhost_only` forced) |
| `/v1/webhooks/message` | Available | Disabled |
| `localhost_only=false` | Configurable | Always `true`; cannot be unset |
| Creating `kind="message"` | Allowed | Rejected (`403`) |
On Lite, every webhook is auto-created with `localhost_only=true`. A `PATCH` that tries to clear it returns `403`.
## Security
- **SSRF protection.** `media_url` and `callback_url` are validated against the SSRF policy and HEAD-probed; callback URLs are re-validated at delivery time to defeat DNS rebinding (`security.webhook.callback_ssrf_blocked`).
- **Secret storage.** Only `SHA-256(secret)` and an AES-256-GCM ciphertext are persisted; raw secrets are never logged.
- **Tenant isolation.** Agents and channels must belong to the webhook's tenant (`security.webhook.tenant_mismatch`).
- **Rotation.** `POST /v1/webhooks/{id}/rotate` invalidates the old secret immediately — there is no grace window. Coordinate the cutover with your callers.
- **Encryption key.** `GOCLAW_ENCRYPTION_KEY` (base64, 32 raw bytes) must be the same across every gateway replica. Rotating it re-encrypts all webhook secrets via the standard key-rotation migration.
## Audit payload
Every call writes one row to `webhook_calls` with `request_payload`:
```json
{
"body_hash": "",
"meta": { /* handler-specific */ }
}
```
`body_hash` is the SHA-256 of the raw request bytes — used by the idempotency checker to detect body-mismatch replays. For `llm` calls, `meta` mirrors the decoded request fields (`input`, `session_key`, `user_id`, `model`, `mode`, `callback_url`, `metadata`). For `message` calls, `meta` is `{channel_name, chat_id, has_media}`.
## Common pitfalls
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `404` on every `/v1/webhooks/*` route | `GOCLAW_ENCRYPTION_KEY` not set | Set the env var and restart the gateway |
| `401 timestamp skew` | Clock drift | Run NTP on the caller |
| `401 hmac_replay` after retry | Re-sending the same signed payload | Re-sign with a fresh timestamp on each retry |
| `409 idempotency_conflict` | Same `Idempotency-Key` with a different body | Always re-send the exact same body, or use a new key |
| `403 unauthorized` from a Lite gateway | Trying to create a `message` webhook or unset `localhost_only` | Use a Standard gateway or change the request |
| Async callback never arrives | `callback_url` failed SSRF validation or returned `4xx` | Inspect `webhook_calls.status` and the worker logs |
## What's Next
- [REST API → Webhooks](/rest-api)
- [Environment Variables → `GOCLAW_ENCRYPTION_KEY`](/env-vars)
- [Security Hardening](/deploy-security)
- [Channels Overview](/channels-overview)
---
# Workstations
> Run agent commands on remote machines through SSH or Docker, with a per-workstation allowlist and a full audit trail.
## Overview
A **workstation** is a remote execution target registered in GoClaw. When an agent calls the built-in `workstation_exec` tool, the gateway opens a session against the linked workstation, runs the command, streams stdout/stderr back as event-bus chunks, and writes one row to the activity log.
Workstations are tenant-scoped and Standard-edition only. Two backend types are shipped:
| Backend | `backendType` | Transport | Notes |
|---------|---------------|-----------|-------|
| SSH | `ssh` | OpenSSH client + pooled sessions | Inline PEM private key or password. TOFU host-key fingerprinting |
| Docker | `docker` | Docker engine API | Image + container name; useful for ephemeral sandbox targets |
The connection pool is shared per workstation row, so repeated `workstation_exec` calls reuse a warm SSH client instead of paying the TCP+TLS handshake every time.
## Lifecycle
1. **Create** the workstation — POST `/v1/workstations` with `workstationKey`, `name`, `backendType`, and `metadata`.
2. **Test** the connection — `POST /v1/workstations/{id}/test`. The SSH backend dials, runs `echo ok`, and tears down within 5 seconds.
3. **Seed the allowlist** — happens automatically on create. See [Permission model](#permission-model).
4. **Link an agent** — over WebSocket: `workstations.linkAgent` with `{agentId, workstationId, isDefault}`.
5. **Use it** — the agent calls the `workstation_exec` tool. Every call passes through the permission checker and is recorded in `workstation_activity`.
`workstationKey` is the stable slug used in API calls; the regex is `^[a-z0-9][a-z0-9-]{0,99}$`.
## Endpoints
All HTTP endpoints require a tenant-admin gateway token (`Authorization: Bearer `).
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/v1/workstations` | List active workstations |
| `POST` | `/v1/workstations` | Create a workstation |
| `GET` | `/v1/workstations/{id}` | Fetch one (sanitized view) |
| `PUT` | `/v1/workstations/{id}` | Partial update |
| `DELETE` | `/v1/workstations/{id}` | Hard delete (tenant-scoped) |
| `POST` | `/v1/workstations/{id}/test` | Health-check the backend |
| `GET` | `/v1/workstations/{id}/permissions` | List allowlist patterns |
| `POST` | `/v1/workstations/{id}/permissions` | Add a pattern (default enabled) |
| `DELETE` | `/v1/workstations/{id}/permissions/{permId}` | Remove a pattern |
| `PUT` | `/v1/workstations/{id}/permissions/{permId}/toggle` | Enable/disable a pattern |
| `GET` | `/v1/workstations/{id}/activity` | Paged audit log (`limit`, `cursor`) |
The same operations are also exposed as WebSocket RPC methods under `workstations.*` (see [WebSocket Protocol](/websocket-protocol)). `workstations.linkAgent` / `workstations.unlinkAgent` are only available over WebSocket.
### Create
```bash
curl -X POST https://gw.example.com/v1/workstations \
-H "Authorization: Bearer " \
-H "Content-Type: application/json" \
-d '{
"workstationKey": "build-vm",
"name": "Build VM (us-west)",
"backendType": "ssh",
"metadata": {
"host": "10.0.4.21",
"port": 22,
"user": "deploy",
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...\n-----END OPENSSH PRIVATE KEY-----\n",
"knownHostsFingerprint": "SHA256:abcdef..."
},
"defaultCwd": "/srv/builds",
"defaultEnv": {"PATH": "/usr/local/bin:/usr/bin"}
}'
```
Response (201 Created) returns a **sanitized view** — the private key and password are never echoed back. Only a metadata summary (`host`, `port`, `user`, `hasKey`) is included:
```json
{
"workstation": {
"id": "1c2d...",
"workstationKey": "build-vm",
"tenantId": "...",
"name": "Build VM (us-west)",
"backendType": "ssh",
"defaultCwd": "/srv/builds",
"active": true,
"createdAt": "2026-05-21T12:00:00Z",
"metadataSummary": {"host": "10.0.4.21", "port": 22, "user": "deploy", "hasKey": true}
}
}
```
### SSH metadata
| Field | Required | Notes |
|-------|----------|-------|
| `host` | yes | DNS name or IP |
| `user` | yes | Remote user |
| `port` | no | Defaults to `22`; must be 1–65535 |
| `privateKey` | one of `privateKey`/`password` | PEM-encoded; stored AES-256-GCM encrypted |
| `password` | one of `privateKey`/`password` | Prefer key auth |
| `knownHostsFingerprint` | recommended | `SHA256:...` of the host key. Empty → TOFU on first connect |
| `connectTimeoutSec` | no | Overrides the 10s TCP dial timeout |
### Docker metadata
| Field | Required | Notes |
|-------|----------|-------|
| `image` | yes | Container image reference |
| `host` | one of `host`/`socketPath` | Remote Docker daemon URL |
| `socketPath` | one of `host`/`socketPath` | Local UNIX socket path |
| `network` | no | Docker network to attach to |
## Permission model
Workstations run a **default-deny** allowlist scoped to `argv[0]` — the binary name. Wildcard `*` is intentionally not supported.
`workstation_permissions` rows live in their own table (migration `000063`) and seed automatically when a workstation is created. The default seed is a small set of read-only or low-risk binaries:
```
echo, pwd, ls, cat, git, whoami, hostname, date, uname, claude
```
Shells (`bash`, `sh`, `zsh`) are deliberately excluded — adding one would bypass the entire model by accepting arbitrary commands as arguments.
Each pattern is either a literal binary name (`git`) or a prefix-glob (`python*`). To extend the allowlist:
```bash
curl -X POST https://gw.example.com/v1/workstations//permissions \
-H "Authorization: Bearer " \
-d '{"pattern": "make"}'
```
Toggle a pattern off without deleting it:
```bash
curl -X PUT https://gw.example.com/v1/workstations//permissions//toggle \
-H "Authorization: Bearer " \
-d '{"enabled": false}'
```
A disabled pattern stays in the table but is skipped by the runtime checker.
## Linking an agent
Use the WebSocket RPC method `workstations.linkAgent`:
```json
{
"id": "req-1",
"method": "workstations.linkAgent",
"params": {
"agentId": "",
"workstationId": "",
"isDefault": true
}
}
```
The `agent_workstation_links` table allows one default workstation per agent — setting `isDefault=true` clears any prior default. To unlink, call `workstations.unlinkAgent` with the same `agentId` and `workstationId`.
When an agent runs the `workstation_exec` tool without explicitly naming a workstation, the gateway resolves it through this link table.
## Activity audit
Every call to `workstation_exec` writes one row to `workstation_activity` (migration `000064`). The table is append-only and pruned nightly via `Prune(before)` on the store interface.
| Field | Notes |
|-------|-------|
| `action` | `"exec"` or `"deny"` |
| `cmdHash` | SHA-256 of the full command (forensics) |
| `cmdPreview` | First 200 chars with secrets redacted |
| `exitCode`, `durationMs` | Populated for `exec` rows; null for `deny` |
| `denyReason` | Populated for `deny` rows (e.g. `"binary 'curl' not allowed"`) |
Read the log paged from newest to oldest:
```bash
curl "https://gw.example.com/v1/workstations//activity?limit=50" \
-H "Authorization: Bearer "
```
The response includes `activity` (array) and `nextCursor` (pass back as `?cursor=...` to page further).
## Using `workstation_exec`
Once a workstation is linked and the allowlist contains the binary you need, an agent can call the tool directly. The tool streams stdout/stderr as event-bus chunks (`execChunkSize` 64 KiB), enforces caps on command length (`4 KiB`), argument bytes (`1 KiB`), env count (`50`) and env value size (`256` bytes each), and returns the exit code plus a 2 KiB tail of each stream.
Each call rebuilds env from `defaultEnv` on the workstation row plus any overrides passed to the tool. Env keys denied by [`env_denylist`](/cli-credentials) (e.g. `LD_PRELOAD`, AWS root creds) are stripped before the SSH session is created.
## Security considerations
- **Encrypted at rest.** `metadata` and `defaultEnv` are stored AES-256-GCM encrypted. The same `GOCLAW_ENCRYPTION_KEY` env var that gates webhooks also encrypts workstation credentials.
- **Sanitized responses.** Every API response uses `SanitizedWorkstation` — private keys, passwords, and the raw `defaultEnv` map never leave the gateway.
- **Tenant isolation.** Every store query is tenant-scoped; the handler also re-checks ownership via `GetByID` before permission and activity operations.
- **Host-key TOFU.** A workstation with an empty `knownHostsFingerprint` accepts the first host key and pins it for all subsequent connections. Pre-populate the fingerprint for production targets.
- **Env denylist.** Forbidden env keys (loader hooks, ambient cloud credentials) are stripped before exec — see [CLI Credentials](/cli-credentials) for the full list.
- **Default-deny allowlist.** Only seeded safe binaries can run until an admin extends the list. There is no `*` shortcut.
## Common workflows
**Wire a remote build host to an agent.**
```bash
# 1. Create the workstation
curl -X POST .../v1/workstations -d @ssh-build-host.json
# 2. Extend the allowlist with the binaries the build needs
curl -X POST .../v1/workstations//permissions -d '{"pattern":"make"}'
curl -X POST .../v1/workstations//permissions -d '{"pattern":"npm"}'
# 3. Link the agent (over WebSocket) with isDefault=true
# 4. Ask the agent to run `make build`; the tool streams output back live.
```
**Audit what an agent ran overnight.**
```bash
curl ".../v1/workstations//activity?limit=200" \
-H "Authorization: Bearer "
```
## Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| `501 not implemented` from `/test` | Test connection is a stub; full implementation pending | Validate connectivity with a tiny `echo` exec instead |
| `invalid slug: workstationKey` | Key contains uppercase, underscores, or `>100` chars | Use kebab-case ASCII, e.g. `build-vm-west` |
| `invalid metadata shape: ssh: privateKey or password is required` | Empty creds in SSH metadata | Supply either an inline PEM or a password |
| `binary 'curl' not allowed` deny rows | Allowlist doesn't include the binary | Add a pattern via `POST /v1/workstations/{id}/permissions` |
| `404 workstation not found` after a cross-tenant call | Workstation belongs to a different tenant | Use a token scoped to the correct tenant |
| `ssh: health check dial` errors | Host unreachable, wrong port, or bad fingerprint | Verify network reachability and `knownHostsFingerprint` |
## What's Next
- [Tools Overview](/tools-overview) — how `workstation_exec` fits among the built-in tools
- [CLI Credentials](/cli-credentials) — env denylist, secret injection
- [REST API → Workstations](/rest-api)
- [WebSocket Protocol → workstations.*](/websocket-protocol)
- [Database Schema](/database-schema) — `workstations`, `workstation_permissions`, `workstation_activity`
---
# Usage & Quota
> Track token consumption per agent and session, and enforce per-user request limits across hour, day, and week windows.
## Overview
GoClaw gives you two related but distinct features:
- **Usage tracking** — how many tokens each agent/session consumed, queryable via the dashboard or WebSocket.
- **Quota enforcement** — optional per-user/group message limits (e.g., 10 requests/hour for Telegram users) backed by the traces table.
Both are always available when PostgreSQL is connected. Quota enforcement is opt-in via config.
## Edition Rate Limits (Sub-Agent)
Starting with v3 (#600), the active **edition** enforces tenant-scoped sub-agent concurrency limits. These prevent a single tenant from monopolizing sub-agent resources.
| Edition field | Lite default | Standard default | Description |
|---|---|---|---|
| `MaxSubagentConcurrent` | 2 | unlimited (0) | Max sub-agents running in parallel per tenant |
| `MaxSubagentDepth` | 1 | uses config default | Max spawn nesting depth (1 = no sub-agents spawning sub-agents) |
A value of `0` means unlimited. Lite edition is the constrained preset; Standard edition ships with no concurrency caps.
When a spawn request would exceed `MaxSubagentConcurrent`, GoClaw rejects the spawn and returns an error to the parent agent. When `MaxSubagentDepth` is exceeded, nested delegation via `team_tasks` is blocked (`SubagentDenyAlways`).
These limits are edition-level — they apply to every tenant on that GoClaw instance regardless of per-agent budget settings.
---
## Quota Enforcement
Quota is checked against the `traces` table (top-level traces only — sub-agent delegations don't count against user quota). Counts are cached in memory for 60 seconds to avoid hammering the database on every request.
### Config
Add a `quota` block inside `gateway` in your `config.json`:
```json
{
"gateway": {
"quota": {
"enabled": true,
"default": { "hour": 20, "day": 100, "week": 500 },
"channels": {
"telegram": { "hour": 10, "day": 50 }
},
"providers": {
"anthropic": { "day": 200 }
},
"groups": {
"group:telegram:-1001234567": { "hour": 5, "day": 20 }
}
}
}
}
```
All limits are optional — a value of `0` (or omitting the field) means unlimited.
**Priority order (most specific wins):** `groups` > `channels` > `providers` > `default`
| Field | Key format | Description |
|-------|-----------|-------------|
| `default` | — | Fallback for any user not matched by a more specific rule |
| `channels` | Channel name, e.g. `"telegram"` | Applies to all users on that channel |
| `providers` | Provider name, e.g. `"anthropic"` | Applies when that LLM provider is used |
| `groups` | User/group ID, e.g. `"group:telegram:-100123"` | Per-user or per-group override |
### What happens when quota is exceeded
The channel layer checks quota before dispatching a message to the agent. If the user is over limit, the agent never runs and the user receives an error message. The response includes which window was exceeded and the current counts:
```
Quota exceeded: 10/10 requests this hour. Try again later.
```
### `quota.usage` — dashboard view
```json
{ "type": "req", "id": "3", "method": "quota.usage" }
```
Response when quota is enabled:
```json
{
"enabled": true,
"requestsToday": 284,
"inputTokensToday": 1240000,
"outputTokensToday": 310000,
"costToday": 1.84,
"uniqueUsersToday": 12,
"entries": [
{
"userId": "user:telegram:123456",
"hour": { "used": 3, "limit": 10 },
"day": { "used": 47, "limit": 100 },
"week": { "used": 200, "limit": 500 }
}
]
}
```
`entries` is capped at 50 users (the top 50 by weekly request count).
When quota is disabled (`"enabled": false`), the response still includes today's aggregate stats (`requestsToday`, `inputTokensToday`, `costToday`, etc.) — the `entries` array is empty and `"enabled": false`.
---
## AI Budget Usage Caps
Separate from the channel-layer request quota above, GoClaw has a **token- and cost-based cap system** that enforces limits on the LLM provider spend itself. Where request quota counts messages, usage caps count *tokens and dollar cost*, and they apply not only to the main agent turn but to every billable LLM call GoClaw makes on a tenant's behalf.
Caps are stored as **policies** in PostgreSQL (migrations `000070`–`000072`) and managed through a REST API. They are always active once at least one policy exists for a tenant — there is no global on/off config flag.
### Policy model
A cap policy answers: *for this scope, in this time window, how many tokens and/or how much cost is allowed?*
**Scope dimensions** — every policy can leave any dimension unset (NULL) to mean "matches everything":
| Dimension | Meaning |
|-----------|---------|
| `tenant_id` | Always set — every policy belongs to one tenant |
| `agent_id` | Limit one agent (unset = all agents in the tenant) |
| `provider_id` | Limit one configured LLM provider record |
| `provider_type` | Limit a provider family, e.g. `anthropic`, `openai` |
| `model_id` | Limit one model, e.g. `claude-sonnet-4-5` |
**Window types** (`window`): `hour`, `day`, `week`, or `month`. Windows are aligned to UTC calendar boundaries — `day` starts at 00:00 UTC, `week` starts Monday, `month` starts on the 1st.
**Limits** — a policy must set `max_tokens`, `max_cost_micros`, or both:
| Field | Unit |
|-------|------|
| `max_tokens` | Total tokens (input + output + cache read + cache write) |
| `max_cost_micros` | Cost in **micro-dollars** (1 USD = 1,000,000 micros). The API also accepts `max_cost_usd` as a convenience and converts it for you |
**How multiple matching policies combine.** When a request comes in, GoClaw finds *every* enabled policy whose scope matches (NULL dimensions match anything, set dimensions must equal the request). A request must fit under **all** matched policies — the most restrictive one wins. The `priority` field (lower number = evaluated first, default `100`) controls evaluation order, which determines which policy is reported as the blocker when a cap is hit.
### Reservation + counter enforcement
Caps use a **reserve-then-settle** model so concurrent calls can't overspend a window:
1. **Preflight (reserve)** — before the LLM call, GoClaw estimates token usage (and cost, if any matched policy has a cost cap), then atomically adds it to each policy's per-window counter. If the reservation would push `used + reserved + estimate` over a policy's `max_tokens` or `max_cost_micros`, the call is **blocked** before it runs.
2. **Reconcile (settle)** — after the call returns, the reservation is replaced with the *actual* tokens and cost from the provider's usage response. The reserved amount is released and the used amount is recorded. Failed calls settle to zero (the reservation is freed) unless a partial response was received.
Cost estimation needs a price for the model. If a cost-capped policy matches but no pricing is known for the model, the call is **blocked** with reason `pricing_unknown` — see [Cost Tracking → Model Pricing](/cost-tracking) for how to populate prices.
### Caps apply to auxiliary LLM calls too
Usage caps are enforced on **every billable LLM call**, not just the user-facing agent turn. This includes auxiliary calls GoClaw makes internally:
- Conversation title generation
- Intent classification
- Mid-loop and history compaction
- Memory flush
- Knowledge-graph extraction
- Memory consolidation (dreaming / episodic workers)
- Vault enrichment
- Provider verification and summoner regeneration
Each of these reserves and reconciles against the same policies, so a tight `day` token cap will also throttle background work, not only chat replies.
> **Subscription / OAuth providers are exempt.** Caps only apply to providers billed by API key. Providers that are flat-rate or subscription-based (Claude CLI, ChatGPT OAuth, Bailian, ACP, Ollama) are skipped, and so are providers configured without an API key.
### Agent monthly budget bridge
The legacy per-agent `budget_monthly_cents` field (see [Cost Tracking → Monthly Budget Enforcement](/cost-tracking)) is automatically mirrored into the cap system. Migration `000072` creates, for every agent with a positive `budget_monthly_cents`, a managed `month`-window cost-cap policy:
- `max_cost_micros` = `budget_monthly_cents × 10,000` (cents → micro-dollars)
- `source` = `agent_budget_monthly_cents`, `priority` = `90`
These bridged policies are **managed** — the REST API refuses to edit or delete them (returns `409 Conflict`). Adjust the agent's `budget_monthly_cents` instead. Manually created policies have `source` = `manual`.
### What happens when a cap is hit
When a reservation is rejected, GoClaw returns a `usage cap exceeded` error from the LLM call. For the main agent turn this surfaces as a failed run — the agent does not produce a reply. A `block` decision is also written to the events log (see below) recording which policy and reason (`cap_exceeded` or `pricing_unknown`) triggered it.
### Decision tracing
Every cap decision is recorded in two places:
- **`usage_cap_events`** — an append-only audit log of `allow` / `block` / `skip` decisions with the policy, reservation key, estimated/actual tokens and cost, and reason. Queryable via the events endpoint.
- **Trace metadata** — each agent trace carries a `usage_caps` block listing the decision, matched policy IDs, reservation key, and estimated vs. actual tokens/cost per LLM attempt, so you can see cap behavior inline with the rest of the trace.
### REST endpoints
All usage-cap endpoints require an admin Bearer token. Write operations (create/update/delete policy, pricing overrides) additionally require tenant-admin scope; the OpenRouter sync endpoint requires master scope.
| Method & path | Scope | Description |
|---------------|-------|-------------|
| `GET /v1/usage-caps/policies` | admin | List all cap policies for the tenant (includes disabled) |
| `POST /v1/usage-caps/policies` | tenant-admin | Create a cap policy |
| `PATCH /v1/usage-caps/policies/{id}` | tenant-admin | Update a policy (managed agent-budget policies are rejected) |
| `DELETE /v1/usage-caps/policies/{id}` | tenant-admin | Delete a policy (managed policies are rejected) |
| `GET /v1/usage-caps/utilization` | admin | Current per-policy usage vs. limit for the active window |
| `GET /v1/usage-caps/events` | admin | Recent cap decisions (`?limit=`, default 50, max 200) |
**Create a policy** — cap one agent to 1M tokens per day:
```bash
curl -X POST -H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
"http://localhost:8080/v1/usage-caps/policies" \
-d '{
"agent_id": "11111111-1111-1111-1111-111111111111",
"window": "day",
"max_tokens": 1000000
}'
```
**Create a cost cap** — limit Anthropic spend to $20/month tenant-wide (`max_cost_usd` is converted to micros):
```bash
curl -X POST -H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
"http://localhost:8080/v1/usage-caps/policies" \
-d '{
"provider_type": "anthropic",
"window": "month",
"max_cost_usd": 20.00
}'
```
**Check utilization:**
```bash
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/usage-caps/utilization"
```
Model pricing (which feeds the cost calculation behind cost caps) is configured through a separate set of endpoints — see [Cost Tracking → Model Pricing](/cost-tracking).
---
## Webhook Rate Limiting (Channel Layer)
Separate from per-user quota, there is a webhook-level rate limiter that protects against incoming webhook floods. It uses a fixed 60-second window with a hard cap of **30 requests per key** per window. Up to **4096 unique keys** are tracked simultaneously; beyond that, oldest entries are evicted.
This rate limiter operates at the HTTP webhook receiver layer, before messages reach the agent. It is not configurable — it is a fixed DoS protection measure.
---
## Database Index
Quota lookups use a partial index added in migration `000009`:
```sql
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_traces_quota
ON traces (user_id, created_at DESC)
WHERE parent_trace_id IS NULL AND user_id IS NOT NULL;
```
This index covers 89% of traces (top-level only) and makes hourly/daily/weekly window queries fast even with large trace tables.
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| `quota.usage` returns `enabled: false` | `quota.enabled` not set to `true` in config | Set `"enabled": true` in `gateway.quota` |
| Users hit quota despite low usage | Cache TTL is 60s — counts lag by up to 1 minute | Expected behavior; the optimistic increment mitigates rapid bursts |
| `requestsToday` is 0 even with activity | No traces written — tracing may be disabled | Ensure PostgreSQL is connected and `GOCLAW_POSTGRES_DSN` is set |
| Quota not enforced on a channel | Channel name in config doesn't match actual channel key | Use exact channel name: `telegram`, `discord`, `feishu`, `zalo`, `whatsapp` |
| Sub-agent messages count against user quota | They shouldn't — only top-level traces count | Verify `parent_trace_id IS NULL` filter; check if agent is delegating via subagent tool |
---
## What's Next
- [Observability](/deploy-observability) — OpenTelemetry tracing and Jaeger integration
- [Security Hardening](/deploy-security) — rate limiting at the gateway level
- [Database Setup](/deploy-database) — PostgreSQL setup including the quota index
---
# Cost Tracking
> Monitor token costs per agent and provider using configurable per-model pricing.
## Overview
GoClaw calculates USD costs for every LLM call when you configure pricing in `telemetry.model_pricing`. Cost data is stored on individual trace spans and aggregated into the `usage_snapshots` table. You can view it via the REST usage API or the WebSocket `quota.usage` method.
Cost tracking requires:
- PostgreSQL connected (`GOCLAW_POSTGRES_DSN`)
- `telemetry.model_pricing` configured in `config.json`
If pricing is not configured, token counts are still tracked — only dollar amounts will be zero.
## How Cost Is Calculated
For each LLM call, GoClaw computes:
```
cost = (prompt_tokens × input_per_million / 1_000_000)
+ (completion_tokens × output_per_million / 1_000_000)
+ (cache_read_tokens × cache_read_per_million / 1_000_000) // if > 0
+ (cache_creation_tokens × cache_create_per_million / 1_000_000) // if > 0
```
Token counts come directly from the provider's API response. Cost is recorded on the LLM call span and rolled up to the trace level. Tools that make internal LLM calls (e.g., `read_image`, `read_document`) also have their costs tracked separately on their own spans.
---
## Querying Cost Data
### REST API
Cost is included in the standard usage endpoints. All endpoints require `Authorization: Bearer ` if `gateway.token` is set.
**`GET /v1/usage/summary`** — current vs. previous period totals:
```bash
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/usage/summary?period=30d"
```
```json
{
"current": {
"requests": 1240,
"input_tokens": 8420000,
"output_tokens": 1980000,
"cost": 42.31,
"unique_users": 18,
"errors": 3,
"llm_calls": 3810,
"tool_calls": 6200,
"avg_duration_ms": 3200
},
"previous": {
"requests": 890,
"cost": 29.17,
...
}
}
```
`period` values: `24h` (default), `today`, `7d`, `30d`.
**`GET /v1/usage/breakdown`** — cost grouped by provider, model, or channel:
```bash
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/usage/breakdown?from=2026-03-01T00:00:00Z&to=2026-03-16T00:00:00Z&group_by=model"
```
```json
{
"rows": [
{
"group": "claude-sonnet-4-5",
"input_tokens": 6100000,
"output_tokens": 1400000,
"total_cost": 35.10,
"request_count": 820
},
{
"group": "gpt-4o",
"input_tokens": 2320000,
"output_tokens": 580000,
"total_cost": 7.21,
"request_count": 420
}
]
}
```
`group_by` options: `provider` (default), `model`, `channel`.
**`GET /v1/usage/timeseries`** — cost over time:
```bash
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/usage/timeseries?from=2026-03-01T00:00:00Z&to=2026-03-16T00:00:00Z&group_by=hour"
```
```json
{
"points": [
{
"bucket_time": "2026-03-01T00:00:00Z",
"request_count": 48,
"input_tokens": 320000,
"output_tokens": 78000,
"total_cost": 1.73,
"llm_call_count": 142,
"tool_call_count": 230,
"error_count": 0,
"unique_users": 5,
"avg_duration_ms": 2800
}
]
}
```
**Common query parameters** (timeseries and breakdown):
| Parameter | Example | Notes |
|-----------|---------|-------|
| `from` | `2026-03-01T00:00:00Z` | RFC 3339, required |
| `to` | `2026-03-16T00:00:00Z` | RFC 3339, required |
| `group_by` | `hour`, `model`, `provider`, `channel` | Defaults vary per endpoint |
| `agent_id` | UUID | Filter by agent |
| `provider` | `anthropic` | Filter by provider |
| `model` | `claude-sonnet-4-5` | Filter by model |
| `channel` | `telegram` | Filter by channel |
### WebSocket
The `quota.usage` method returns today's cost alongside usage counters:
```json
{ "type": "req", "id": "1", "method": "quota.usage" }
```
```json
{
"enabled": true,
"requestsToday": 284,
"inputTokensToday": 1240000,
"outputTokensToday": 310000,
"costToday": 1.84,
"uniqueUsersToday": 12,
"entries": [...]
}
```
`costToday` is always present. If pricing is not configured it will be `0`.
---
## Per-Sub-Agent Token Cost Tracking
As of v3 (#600), token costs are accumulated per sub-agent and included in announce messages. This means:
- Each spawned sub-agent accumulates its own `input_tokens` and `output_tokens` independently
- When a sub-agent completes, its token totals are included in the announce message sent to the parent agent's LLM context
- Token costs are persisted to the `subagent_tasks` table (migration 000034) for billing and observability queries
- Sub-agent token costs roll up to the parent trace's cost via the existing trace span hierarchy
Sub-agent costs appear in the same REST endpoints (`/v1/usage/timeseries`, `/v1/usage/breakdown`) under the sub-agent's own `agent_id`. To see the total cost of a multi-agent workflow, sum costs across all `agent_id` values that share the same root trace.
---
## Monthly Budget Enforcement
You can cap an agent's monthly spend by setting `budget_monthly_cents` on the agent record. When set, GoClaw queries the current month's accumulated cost before each run and blocks execution if the budget is exceeded.
Set via the agents API or directly in the `agents` table:
```json
{
"budget_monthly_cents": 500
}
```
This example sets a $5.00/month limit. When the agent hits the limit, it returns an error:
```
monthly budget exceeded ($5.02 / $5.00)
```
The check runs once per request, before any LLM calls. Sub-agent delegations run under their own agent records with their own budgets.
> The agent's `budget_monthly_cents` is also mirrored into the [AI Budget Usage Caps](/usage-quota) system as a managed `month`-window cost-cap policy, so the same limit is enforced there too.
---
## Model Pricing (Catalog & Overrides)
The static `telemetry.model_pricing` map above is the simplest way to price models. For dynamic, database-backed pricing — used by the [AI Budget Usage Caps](/usage-quota) cost caps — GoClaw also keeps a **pricing catalog** and per-tenant **overrides** in PostgreSQL (migration `000070`).
### OpenRouter catalog sync
GoClaw can pull a full model price list from [OpenRouter](https://openrouter.ai) and store it in the `usage_pricing_catalog` table. Each entry captures per-unit prices for input, output, cache read/write, reasoning, request, image, and web-search units, stored as high-precision decimals.
```bash
curl -X POST -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/model-pricing/sync-openrouter"
```
This endpoint requires **master scope** (it updates the shared global catalog). The catalog is upserted by `model_id`, so re-running the sync refreshes prices in place.
### Per-model price overrides
When you want different prices for a specific tenant + provider + model (e.g. a negotiated rate, or a model OpenRouter doesn't list), set an **override** in the `usage_pricing_overrides` table. Overrides are scoped to one tenant and take priority over the global catalog.
```bash
# List the synced catalog (optionally filter by model)
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/model-pricing?model=claude-sonnet-4-5"
# Set a tenant override
curl -X PUT -H "Authorization: Bearer your-token" \
-H "Content-Type: application/json" \
"http://localhost:8080/v1/model-pricing/overrides" \
-d '{
"provider_id": "22222222-2222-2222-2222-222222222222",
"provider_type": "anthropic",
"model_id": "claude-sonnet-4-5",
"pricing": { "input": "0.000003", "output": "0.000015" }
}'
# List / delete overrides
curl -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/model-pricing/overrides"
curl -X DELETE -H "Authorization: Bearer your-token" \
"http://localhost:8080/v1/model-pricing/overrides/{id}"
```
| Method & path | Scope | Description |
|---------------|-------|-------------|
| `POST /v1/model-pricing/sync-openrouter` | master | Refresh the global price catalog from OpenRouter |
| `GET /v1/model-pricing` | admin | List the synced catalog (`?model=`, `?limit=`) |
| `PUT /v1/model-pricing/overrides` | tenant-admin | Create or update a per-tenant model price override |
| `GET /v1/model-pricing/overrides` | admin | List overrides (`?provider_id=`) |
| `DELETE /v1/model-pricing/overrides/{id}` | tenant-admin | Remove an override |
### How pricing resolves
When a cost cap needs a price for a model, GoClaw resolves in this order:
1. **Tenant override** for the matching provider + model (highest priority)
2. **Global OpenRouter catalog** entry by `model_id` / canonical id
If neither is found, a cost-capped call is blocked with reason `pricing_unknown`. Prices are stored as decimal USD **per token/unit** and converted to micro-dollars when caps compute cost.
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| `cost` is always `0` in API responses | `model_pricing` not configured | Add pricing under `telemetry.model_pricing` in `config.json` |
| Cost recorded for some models only | Key mismatch in pricing map | Use exact `"provider/model"` key (e.g., `"anthropic/claude-sonnet-4-5"`) or bare model name |
| Budget check blocks all runs | Monthly cost already exceeds `budget_monthly_cents` | Increase the budget or reset it; costs reset automatically at month rollover |
| Timeseries/breakdown returns empty | `from`/`to` missing or outside snapshot range | Snapshots are hourly; data older than retention period may be pruned |
| `costToday` in `quota.usage` is stale | Snapshots are pre-aggregated hourly | The current incomplete hour is gap-filled live from traces |
---
## What's Next
- [Usage & Quota](/usage-quota) — per-user request limits and token counts
- [Observability](/deploy-observability) — OpenTelemetry export for spans including cost fields
- [Configuration Reference](/config-reference) — full `telemetry` config options
---
# Model Steering
> How GoClaw guides small models through 3 control layers: Track (scheduling), Hint (contextual nudges), and Guard (safety boundaries).
## Overview
Small models (< 70B params) running agent loops commonly hit three problems:
| Problem | Symptom |
|---------|---------|
| **Losing direction** | Uses up iteration budget without answering, loops on meaningless tool calls |
| **Forgetting context** | Doesn't report progress, ignores existing information |
| **Safety violations** | Runs dangerous commands, falls to prompt injection, writes malicious code |
GoClaw addresses these with **3 steering layers** that run concurrently on every request:
```mermaid
flowchart LR
REQ([Request]) --> TRACK
subgraph TRACK["Track — Where to run?"]
direction TB
T1[Lane routing]
T2[Concurrency control]
T3[Session serialization]
end
TRACK --> GUARD
subgraph GUARD["Guard — What's allowed?"]
direction TB
G1[Input validation]
G2[Shell deny patterns]
G3[Skill content scan]
end
GUARD --> HINT
subgraph HINT["Hint — What should it do?"]
direction TB
H1[Budget warnings]
H2[Error guidance]
H3[Progress nudges]
end
HINT --> LOOP([Agent Loop])
```
**Design principles:**
- **Track** — infrastructure layer; the model has no visibility into which lane it runs on
- **Guard** — hard boundary; blocks dangerous behavior regardless of which model is running
- **Hint** — soft guidance; injected as messages into the conversation; the model can ignore hints (but usually doesn't)
## Hint System (Contextual Guidance Injection)
Hints are **messages injected into the conversation** at strategic points during the agent loop. Small models benefit most from hints because they tend to forget initial instructions as conversations grow long.
### When Hints Are Injected
```mermaid
flowchart TD
subgraph LOOP["Agent Loop Phases"]
PH3["Phase 3: Build Messages"]
PH4["Phase 4: LLM Iteration"]
PH5["Phase 5: Tool Execution"]
end
CH["Channel Formatting Hint"] -.-> PH3
SR["System Prompt Reminders"] -.-> PH3
BH["Budget Hint (75%)"] -.-> PH4
OT["Output Truncation Hint"] -.-> PH4
SE["Skill Nudge (70% / 90%)"] -.-> PH4
TN["Team Progress Nudge (every 6 iter)"] -.-> PH4
SH["Sandbox Error Hint"] -.-> PH5
TC["Task Creation Guide"] -.-> PH5
```
### 8 Hint Types
#### 1. Budget Hints — Preventing Directionless Looping
Fires when the model uses up its iteration budget without producing a text response:
| Trigger | Injected Message |
|---------|-----------------|
| 75% of iterations used, no text response yet | "You've used 75% of your budget. Start synthesizing results." |
| Max iterations reached | Loop stops and returns final result |
This is especially effective with small models — instead of letting them loop indefinitely, it forces early summarization.
#### 2. Output Truncation Hints — Error Recovery
When the LLM response is cut off due to `max_tokens`:
> `[System] Output was truncated. Tool call arguments are incomplete. Retry with shorter content — split writes or reduce text.`
Small models often don't recognize that their output was truncated. This hint explains the cause and prompts them to adjust.
#### 3. Skill Evolution Nudges — Encouraging Self-Improvement
| Trigger | Content |
|---------|---------|
| 70% of iteration budget used | Suggests creating a skill to reuse the current workflow |
| 90% of iteration budget used | Stronger reminder about skill creation |
These hints are **ephemeral** (not persisted to session history) and support **i18n** (en/vi/zh).
#### 4. Team Progress Nudges — Progress Reporting Reminders
Every 6 iterations when the agent is working on a team task:
> `[System] You're at iteration 12/20 (~60% budget) for task #3: 'Implement auth module'. Report progress now: team_tasks(action="progress", percent=60, text="...")`
Without this, small models tend to forget to call progress reporting → the lead agent doesn't know the status → bottleneck.
#### 5. Sandbox Error Hints — Explaining Environment Errors
When a command in a Docker sandbox encounters an error, the hint is **attached directly to the error output**:
| Error Pattern | Hint |
|--------------|------|
| Exit code 127 / "command not found" | Binary not installed in sandbox image |
| "permission denied" / EACCES | Workspace mounted read-only |
| "network is unreachable" / DNS fail | `--network none` is enabled |
| "read-only file system" / EROFS | Writing outside workspace volume |
| "no space left" / ENOSPC | Disk/memory exhausted in container |
| "no such file" | File doesn't exist in sandbox |
Hint priority: exit code 127 is checked first, then pattern-matched in priority order.
#### 6. Channel Formatting Hints — Platform-Specific Guidance
Injected into the system prompt based on the channel type:
- **Zalo** — "Use plain text, no markdown, no HTML"
- **Group chat** — Instructions on using the `NO_REPLY` token when a message doesn't require a response
#### 7. Task Creation Guidance — Lead Agent Help
When the model lists or searches team tasks, the response includes:
- List of team members + their models
- 4 rules: write self-contained descriptions, split complex tasks, match task complexity to model capability, ensure task independence
Especially useful when small models (MiniMax, Qwen) act as lead agents — they tend to create vague tasks or misassign complexity.
#### 8. System Prompt Reminders — Recency Zone Reinforcement
Injected at the end of the system prompt (the "recency zone" — the part the model pays most attention to):
- Reminder to search memory before answering
- Persona/character reinforcement if the agent has a custom identity
- Onboarding nudges for new users
### Hint Summary Table
| Hint | Trigger | Ephemeral? | Injection Point |
|------|---------|:----------:|-----------------|
| Budget 75% | iteration == max×¾, no text yet | Yes | Message list (Phase 4) |
| Output Truncation | `finish_reason == "length"` | Yes | Message list (Phase 4) |
| Skill Nudge 70% | iteration/max ≥ 0.70 | Yes | Message list (Phase 4) |
| Skill Nudge 90% | iteration/max ≥ 0.90 | Yes | Message list (Phase 4) |
| Team Progress | iteration % 6 == 0 and has TeamTaskID | Yes | Message list (Phase 4) |
| Sandbox Error | Pattern match on stderr/exit code | No | Tool result suffix (Phase 5) |
| Channel Format | Channel type == "zalo" etc. | No | System prompt (Phase 3) |
| Task Creation | `team_tasks` list/search response | No | Tool result JSON (Phase 5) |
| Memory/Persona | Config flags | No | System prompt (Phase 3) |
---
## Guard System (Safety Boundaries)
Guards create **hard boundaries** — they don't depend on model compliance. Even if a small model is tricked by a prompt injection attack, guards block dangerous behavior at the infrastructure level.
### 4-Layer Guard Architecture
```mermaid
flowchart TD
INPUT([User Message]) --> IG
subgraph IG["Layer 1: InputGuard"]
IG1["6 regex patterns"]
IG2["Action: log / warn / block / off"]
end
IG --> LOOP([Agent Loop])
LOOP --> TOOL{Tool call?}
TOOL -->|exec / shell| SDG
TOOL -->|write SKILL.md| SCG
TOOL -->|other| SAFE[Allow]
subgraph SDG["Layer 2: Shell Deny Groups"]
SDG1["15 categories, 200+ patterns"]
SDG2["Per-agent overrides"]
end
subgraph SCG["Layer 3: Skill Content Guard"]
SCG1["25 security rules"]
SCG2["Line-by-line scan"]
end
SDG --> RESP([Response])
SCG --> RESP
SAFE --> RESP
RESP --> VG
subgraph VG["Layer 4: Voice Guard"]
VG1["Error → friendly fallback"]
end
```
### Layer 1: InputGuard — Prompt Injection Detection
Scans **every user message** before it enters the agent loop, plus injected messages and web fetch/search results.
| Pattern | Detects |
|---------|---------|
| `ignore_instructions` | "Ignore all previous instructions…" |
| `role_override` | "You are now a…", "Pretend you are…" |
| `system_tags` | ``, `[SYSTEM]`, `[INST]`, `<>`, `<\|im_start\|>system` |
| `instruction_injection` | "New instructions:", "Override:", "System prompt:" |
| `null_bytes` | `\x00` characters (null byte injection) |
| `delimiter_escape` | "End of system", ``, `` |
**4 action modes** (config: `gateway.injection_action`):
| Mode | Behavior |
|------|---------|
| `log` | Log info, do not block |
| `warn` | Log warning (default) |
| `block` | Reject message, return error to user |
| `off` | Disable scanning entirely |
**3 scan points:** incoming user message (Phase 2), mid-run injected messages, and tool results from `web_fetch`/`web_search`.
### Layer 2: Shell Deny Groups — Command Safety
15 deny groups, all **ON by default**. Admin must explicitly allow a group to disable it.
| Group | Example Patterns |
|-------|-----------------|
| `destructive_ops` | `rm -rf`, `mkfs`, `dd if=`, `shutdown`, fork bomb |
| `data_exfiltration` | `curl \| sh`, `wget POST`, DNS lookup, `/dev/tcp/` |
| `reverse_shell` | `nc`, `socat`, `openssl s_client`, Python/Perl socket |
| `code_injection` | `eval $()`, `base64 -d \| sh` |
| `privilege_escalation` | `sudo`, `su`, `doas`, `pkexec`, `runuser`, `nsenter` |
| `dangerous_paths` | `chmod`/`chown` on system paths |
| `env_injection` | `LD_PRELOAD`, `BASH_ENV`, `GIT_EXTERNAL_DIFF` |
| `container_escape` | Docker socket, `/proc/sys/`, `/sys/` |
| `crypto_mining` | `xmrig`, `cpuminer`, `stratum+tcp://` |
| `filter_bypass` | `sed -e`, `git --exec`, `rg --pre` |
| `network_recon` | `nmap`, `ssh`/`scp`/`sftp`, tunneling |
| `package_install` | `pip install`, `npm install`, `apk add` |
| `persistence` | `crontab`, shell RC file writes |
| `process_control` | `kill -9`, `killall`, `pkill` |
| `env_dump` | `env`, `printenv`, `/proc/*/environ`, `GOCLAW_*` |
**Special case:** `package_install` triggers an approval flow (not a hard deny) — the agent pauses and asks the user for permission. All other groups are hard-blocked.
**Per-agent override:** Admins can allow specific deny groups for specific agents via DB config.
### Layer 3: Skill Content Guard
Scans **SKILL.md content** before writing the file. 25 regex rules detect:
- Shell injection and destructive operations
- Code obfuscation (`base64 -d`, `eval`, `curl | sh`)
- Credential theft (`/etc/passwd`, `.ssh/id_rsa`, `AWS_SECRET_ACCESS_KEY`)
- Path traversal (`../../..`)
- SQL injection (`DROP TABLE`, `TRUNCATE`)
- Privilege escalation (`sudo`, `chmod 777`)
Any violation results in a **hard reject** — the file is not written and the model receives an error.
### Layer 4: Voice Guard
Specialized for Telegram voice agents. When voice/audio processing encounters a technical error, Voice Guard replaces the raw error message with a friendly fallback for end users. This is a UX guard, not a security guard.
### Guard Summary
| Guard | Scope | Default Action | Configurable? |
|-------|-------|:--------------:|:-------------:|
| InputGuard | All user messages + injected + tool results | warn | Yes (log/warn/block/off) |
| Shell Deny | All `exec`/`shell` tool calls | hard block | Yes (per-agent group override) |
| Skill Content | SKILL.md file writes | hard reject | No |
| Voice Guard | Telegram voice error replies | friendly fallback | No |
---
## How the 3 Layers Work Together
```mermaid
flowchart TD
REQ([User Request]) --> TRACK_ROUTE
subgraph TRACK["TRACK"]
TRACK_ROUTE["Lane routing"]
TRACK_ROUTE --> QUEUE["Session queue"]
QUEUE --> THROTTLE["Adaptive throttle"]
end
THROTTLE --> GUARD_INPUT
subgraph GUARD["GUARD"]
GUARD_INPUT["InputGuard scan"]
GUARD_INPUT --> LOOP_START["Agent Loop"]
LOOP_START --> TOOL_CALL{Tool call?}
TOOL_CALL -->|exec/shell| SHELL_DENY["Shell Deny Groups"]
TOOL_CALL -->|write skill| SKILL_GUARD["Skill Content Guard"]
TOOL_CALL -->|other| SAFE[Allow]
end
SHELL_DENY --> HINT_INJECT
SKILL_GUARD --> HINT_INJECT
SAFE --> HINT_INJECT
subgraph HINT["HINT"]
HINT_INJECT["Sandbox hints"]
HINT_INJECT --> BUDGET["Budget / truncation hints"]
BUDGET --> PROGRESS["Progress nudges"]
PROGRESS --> SKILL_EVO["Skill evolution nudges"]
end
SKILL_EVO --> LLM([LLM continues iteration])
LLM --> TOOL_CALL
```
| Layer | Question answered | Mechanism | Nature |
|-------|------------------|-----------|--------|
| **Track** | Where to run? | Lane + Queue + Semaphore | Infrastructure, invisible to model |
| **Guard** | What's allowed? | Regex pattern matching, hard deny | Security boundary, model-agnostic |
| **Hint** | What should it do? | Message injection into conversation | Soft guidance, model can ignore |
**When using large models** (Claude, GPT-4): Guard is still necessary. Hint is less critical because large models track context better.
**When using small models** (MiniMax, Qwen, Gemini Flash): all 3 layers are critical.
---
## Mode Prompt System
Beyond the runtime steering layers, GoClaw applies **prompt-level steering** by varying which system prompt sections are included based on context. This reduces token cost for background tasks while keeping full guidance for user-facing interactions.
### Prompt Modes
| Mode | Who gets it | Sections included |
|------|-------------|------------------|
| `full` | Main user-facing agents | All sections — persona, skills, MCP, memory, spawn guidance, recency reinforcements |
| `task` | Enterprise automation agents | Lean but capable — execution bias, skills search, memory slim, safety slim |
| `minimal` | Subagents spawned via `spawn` | Reduced — tooling, safety, workspace, pinned skills only |
| `none` | Identity-only (rare) | Identity line only, no tooling guidance |
**3-layer resolution** (highest priority wins):
1. **Runtime override** — caller passes explicit mode (e.g. subagent dispatch sets `minimal`)
2. **Auto-detect** — heartbeat sessions → `minimal`; subagent/cron sessions → `task` (capped)
3. **Agent config** — `prompt_mode` field in agent config
4. **Default** — `full`
```go
// Priority: runtime > auto-detect > config > default
func resolvePromptMode(runtimeOverride, sessionKey, configMode PromptMode) PromptMode
```
### Orchestration Modes
Each agent is assigned an orchestration mode based on its capabilities. This determines which inter-agent tools are available and which sections appear in the system prompt:
| Mode | How assigned | Tools available | Prompt section |
|------|-------------|----------------|----------------|
| `spawn` | Default (no links or team) | `spawn` only | Sub-Agent Spawning |
| `delegate` | Agent has AgentLink targets | `spawn` + `delegate` | Delegation Targets |
| `team` | Agent is in a team | `spawn` + `delegate` + `team_tasks` | Team Workspace + Team Members |
Resolution priority: team > delegate > spawn.
The `delegate` and `team_tasks` tools are hidden from the LLM unless the agent's mode explicitly enables them (`orchModeDenyTools`).
### Prompt Cache Boundary
For Anthropic providers, GoClaw splits the system prompt at a cache boundary marker:
```
---
# Agent Evolution
> Let predefined agents refine their communication style and build reusable skills over time — automatically, with your consent.
## Overview
GoClaw includes three subsystems that allow predefined agents to evolve their behavior across conversations. All three are **opt-in** and **restricted to predefined agents** — open agents are not eligible.
| Subsystem | What it does | Config key |
|---|---|---|
| Self-Evolution | Agent refines its own tone/voice (SOUL.md) and domain expertise (CAPABILITIES.md) | `self_evolve` |
| Skill Learning Loop | Agent captures reusable workflows as skills | `skill_evolve` |
| Skill Management | Create, patch, delete, and grant skills | `skill_manage` tool |
Both `self_evolve` and `skill_evolve` are disabled by default. Enable them per-agent in **Agent Settings → Config tab**.
## Skill Learning Loop
### What it does
When `skill_evolve` is enabled, GoClaw encourages agents to capture complex multi-step processes as reusable skills. The loop has three touch points:
1. **System prompt guidance** — injected at the start of every request with SHOULD/SHOULD NOT criteria
2. **Budget nudges** — ephemeral reminders injected mid-loop at 70% and 90% of the iteration budget
3. **Postscript suggestion** — appended to the agent's final response when enough tool calls happened; requires explicit user consent
No skill is ever created without the user replying "save as skill". Replying "skip" does nothing.
### Enabling it
| Setting | Location | Default |
|---|---|---|
| `skill_evolve` | Agent Settings → Config tab → Skill Learning toggle | `false` |
| `skill_nudge_interval` | Config tab → interval input | `15` |
`skill_nudge_interval` is the minimum number of tool calls in a run before the postscript fires. Set to `0` to disable postscripts entirely while keeping budget nudges.
Open agents always get `skill_evolve=false` regardless of the database setting — enforcement happens at the resolver level.
### How the loop flows
```
Admin enables skill_evolve
↓
System prompt includes Skill Creation guidance (every request)
↓
Agent processes request (think → act → observe)
↓
≥70% iteration budget? → ephemeral nudge (soft suggestion)
≥90% iteration budget? → ephemeral nudge (moderate urgency)
↓
Agent completes task
↓
totalToolCalls ≥ skill_nudge_interval?
No → Normal response
Yes → Postscript appended: "Save as skill? or skip?"
↓
User replies "skip" → No action
User replies "save as skill" → Agent calls skill_manage(create)
↓
Skill created + auto-granted
↓
Available on next turn
```
### System prompt guidance
When `skill_evolve=true` and the `skill_manage` tool is registered, GoClaw injects this block (~135 tokens per request):
```
### Skill Creation (recommended after complex tasks)
After completing a complex task (5+ tool calls), consider:
"Would this process be useful again in the future?"
SHOULD create skill when:
- Process is repeatable with different inputs
- Multiple steps that are easy to forget
- Domain-specific workflow others could benefit from
SHOULD NOT create skill when:
- One-time task specific to this user/context
- Debugging or troubleshooting (too context-dependent)
- Simple tasks (< 5 tool calls)
- User explicitly said "skip" or declined
Creating: skill_manage(action="create", content="---\nname: ...\n...")
Improving: skill_manage(action="patch", slug="...", find="...", replace="...")
Removing: skill_manage(action="delete", slug="...")
Constraints:
- You can only manage skills you created (not system or other users' skills)
- Quality over quantity — one excellent skill beats five mediocre ones
- Ask user before creating if unsure
```
### Budget nudges
These are ephemeral user messages injected into the agent loop. They are **not** persisted to session history and fire at most once per run each.
**At 70% of iteration budget (~31 tokens):**
```
[System] You are at 70% of your iteration budget. Consider whether any
patterns from this session would make a good skill.
```
**At 90% of iteration budget (~48 tokens):**
```
[System] You are at 90% of your iteration budget. If this session involved
reusable patterns, consider saving them as a skill before completing.
```
### Postscript suggestion
When `totalToolCalls >= skill_nudge_interval`, this text is appended to the agent's final response (~35 tokens, persisted in session):
```
---
_This task involved several steps. Want me to save the process as a
reusable skill? Reply "save as skill" or "skip"._
```
The postscript fires at most once per run. Subsequent runs reset the flag.
### Tool gating
When `skill_evolve=false`, the `skill_manage` tool is completely hidden from the LLM — filtered from tool definitions before they are sent to the provider, and excluded from tool names in system prompt construction. The agent has zero awareness of it.
---
## Skill Management
### skill_manage tool
The `skill_manage` tool is available to agents when `skill_evolve=true`. It supports three actions:
| Action | Required params | What it does |
|---|---|---|
| `create` | `content` | Creates a new skill from a SKILL.md content string |
| `patch` | `slug`, `find`, `replace` | Applies a find-and-replace patch to an existing skill |
| `delete` | `slug` | Soft-deletes a skill (moved to `.trash/`) |
**Full parameter reference:**
| Parameter | Type | Required for | Description |
|---|---|---|---|
| `action` | string | all | `create`, `patch`, or `delete` |
| `slug` | string | patch, delete | Unique skill identifier |
| `content` | string | create | Full SKILL.md including YAML frontmatter |
| `find` | string | patch | Exact text to find in current SKILL.md |
| `replace` | string | patch | Replacement text |
| `files` | object | optional (create, patch) | Companion text files keyed by relative path (e.g. `references/guide.md`). Each file is capped at **2 MB**; paths are validated (no `..`, absolute/Windows paths, null bytes, `SKILL.md` overwrite, dotfiles, or system artifacts) |
| `visibility` | string | optional (patch) | Metadata-only visibility change (`private` or `public`) — updates who can discover the skill without creating a new version when no `content`/`files` change |
**Example — creating a skill from conversation:**
```
skill_manage(
action="create",
content="---\nname: Deploy Checklist\ndescription: Steps to deploy the app safely.\n---\n\n## Steps\n1. Run tests\n2. Build image\n3. Push to registry\n4. Apply manifests\n5. Verify rollout"
)
```
**Example — patching an existing skill:**
```
skill_manage(
action="patch",
slug="deploy-checklist",
find="5. Verify rollout",
replace="5. Verify rollout\n6. Notify team in Slack"
)
```
**Example — deleting a skill:**
```
skill_manage(action="delete", slug="deploy-checklist")
```
**Example — creating a skill with a companion reference file:**
```
skill_manage(
action="create",
content="---\nname: Deploy Checklist\ndescription: Steps to deploy the app safely.\n---\n\n## Steps\nSee {baseDir}/references/runbook.md",
files={"references/runbook.md": "# Runbook\n1. Run tests\n2. Build image\n3. Push to registry"}
)
```
### publish_skill tool
`publish_skill` is an alternative path that registers an entire local directory as a skill. It is always available as a built-in tool toggle (not gated by `skill_evolve`).
```
publish_skill(path="./skills/my-skill")
```
The directory must contain a `SKILL.md` with a `name` in frontmatter. The skill starts with `private` visibility and is auto-granted to the calling agent. Use the Dashboard or API to grant it to other agents.
**Comparison:**
| | `skill_manage` | `publish_skill` |
|---|---|---|
| Input | Content string | Directory path |
| Files | SKILL.md plus direct text companion files (`files=...`); patch copies existing companions forward | Entire directory (scripts, assets, etc.) |
| Gated by | `skill_evolve` config | Built-in tool toggle (always available) |
| Guidance | Injected via skill_evolve prompt | Uses `skill-creator` core skill |
| Auto-grant | Yes | Yes |
---
## Security
Every skill mutation passes through four layers before anything is written to disk.
### Layer 1 — Content Guard
Line-by-line regex scan of the SKILL.md content. Hard-reject on any match. 25 rules across 6 categories:
| Category | Examples |
|---|---|
| Destructive shell | `rm -rf /`, fork bomb, `dd of=/dev/`, `mkfs`, `shred` |
| Code injection | `base64 -d \| sh`, `eval $(...)`, `curl \| bash`, `python -c exec()` |
| Credential exfil | `/etc/passwd`, `.ssh/id_rsa`, `AWS_SECRET_ACCESS_KEY`, `GOCLAW_DB_URL` |
| Path traversal | `../../../` deep traversal |
| SQL injection | `DROP TABLE`, `TRUNCATE TABLE`, `DROP DATABASE` |
| Privilege escalation | `sudo`, world-writable `chmod`, `chown root` |
This is a defense-in-depth layer — not exhaustive. GoClaw's `exec` tool has its own runtime deny-list for shell commands.
### Layer 2 — Ownership Enforcement
Three-layer ownership check across all mutation paths:
| Layer | Check |
|---|---|
| `skill_manage` tool | `GetSkillOwnerIDBySlug(slug)` before patch/delete |
| HTTP API | `GetSkillOwnerID(uuid)` + admin role bypass |
| WebSocket gateway | `skillOwnerGetter` interface + admin role bypass |
Agents can only modify skills they created. Admins can bypass ownership checks. System skills (`is_system=true`) cannot be modified through any path.
### Layer 3 — System Skill Guard
System skills are always read-only. Any attempt to patch or delete a skill with `is_system=true` is rejected before reaching the filesystem.
### Layer 4 — Filesystem Safety
| Protection | Detail |
|---|---|
| Symlink detection | `filepath.WalkDir` checks for symlinks — rejects any |
| Path traversal | Rejects paths containing `..` segments |
| SKILL.md size limit | 100 KB max |
| Companion files size limit | 20 MB max total (scripts, assets) |
| Soft-delete | Files moved to `.trash/`, never hard-deleted |
---
## Versioning and Storage
Each create or patch produces a new immutable version directory. GoClaw always serves the highest-numbered version.
```
skills-store/
├── deploy-checklist/
│ ├── 1/
│ │ └── SKILL.md
│ └── 2/ ← patch created this version
│ └── SKILL.md
├── .trash/
│ └── old-skill.1710000000 ← soft-deleted
```
Concurrent version creation for the same skill is serialized via `pg_advisory_xact_lock` keyed on FNV-64a hash of the slug. Version numbers are computed inside the transaction using `COALESCE(MAX(version), 0) + 1`.
---
## Token Cost
| Component | When active | Approx tokens | Persisted? |
|---|---|---|---|
| Self-evolve section | `self_evolve=true` | ~95 | Every request |
| Skill creation guidance | `skill_evolve=true` | ~135 | Every request |
| `skill_manage` tool definition | `skill_evolve=true` | ~290 | Every request |
| Budget nudge 70% | iter ≥ 70% of max | ~31 | No (ephemeral) |
| Budget nudge 90% | iter ≥ 90% of max | ~48 | No (ephemeral) |
| Postscript | toolCalls ≥ interval | ~35 | Yes |
Maximum overhead per run with both features enabled: ~305 tokens for skill learning (~1.5% of a 128K context). When both are disabled (the default), zero token overhead.
---
## v3: Evolution Metrics and Suggestion Engine
v3 adds automated, metrics-driven evolution for predefined agents. This operates separately from the manual skill learning loop above.
### How It Works
```
Metrics collected during agent runs (7-day rolling window)
↓
SuggestionEngine.Analyze() — runs daily via cron
├─ LowRetrievalUsageRule (avg recall < threshold)
├─ ToolFailureRule (single tool failure rate > 20%)
└─ RepeatedToolRule (tool called 5+ consecutive times)
↓
Suggestion created with status "pending"
↓
Admin reviews → approve / reject / rollback
```
### Metric Types
| Type | What is tracked | Examples |
|------|----------------|---------|
| `tool` | Per-tool performance | invocation_count, success_rate, failure_count, avg_duration_ms |
| `retrieval` | Knowledge retrieval quality | recall_rate, precision, relevance_score |
| `feedback` | User satisfaction signals | rating, sentiment, effectiveness_score |
Metrics aggregate over 7-day rolling windows. At least 100 data points are required before a suggestion can be auto-applied (configurable via `min_data_points` guardrail).
### Suggestion Types
| Type | Trigger | Recommendation |
|------|---------|----------------|
| `low_retrieval_usage` | Avg recall below threshold for 7 days | Lower `retrieval_threshold` by ≤ 0.1 |
| `tool_failure` | Single tool failure rate > 20% | Review tool config or add fallback |
| `repeated_tool` | Same tool called 5+ consecutive times | Extract workflow as a skill |
Only one pending suggestion of each type per agent exists at a time (duplicate prevention).
### Auto-Adapt Guardrails
Suggestions can be auto-applied when approved. Guardrails prevent runaway parameter changes:
| Guardrail | Default | Purpose |
|-----------|---------|---------|
| `max_delta_per_cycle` | 0.1 | Max parameter change per apply cycle |
| `min_data_points` | 100 | Minimum metrics required before applying |
| `rollback_on_drop_pct` | 20.0 | Auto-rollback if quality drops >20% after apply |
| `locked_params` | `[]` | Parameters that cannot be auto-changed |
Baseline parameter values are stored in the suggestion's `parameters._baseline` field for rollback.
### Evolution Cron
Analysis runs on a configurable schedule (default: daily at 02:00). Set via `evolution_cron_schedule` in agent config:
```json
{
"evolution_enabled": true,
"evolution_cron_schedule": "every day at 02:00",
"evolution_guardrails": {
"max_delta_per_cycle": 0.1,
"min_data_points": 100,
"rollback_on_drop_pct": 20.0,
"locked_params": []
}
}
```
Set `evolution_enabled: false` to disable all metrics collection for an agent.
### HTTP API
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/agents/{id}/evolution/metrics` | Query/aggregate metrics |
| `GET` | `/v1/agents/{id}/evolution/suggestions` | List suggestions |
| `PATCH` | `/v1/agents/{id}/evolution/suggestions/{sid}` | Approve / reject / rollback |
WebSocket equivalents: `agent.evolution.metrics`, `agent.evolution.suggestions`, `agent.evolution.apply`, `agent.evolution.rollback`.
---
## Skill Self-Evolution (per-skill metrics)
This is a **separate subsystem** from the agent-level "v3 Evolution Metrics" above. Where agent-level evolution tunes an *agent's* parameters, skill self-evolution tracks how each **existing skill** performs over time and proposes improvements to that skill. It is also distinct from the `skill_evolve` learning loop, which teaches an agent *when* to create or patch reusable skills.
### Runtime recording
Usage is recorded automatically as agents use skills — there is **no public usage endpoint**:
- `use_skill` tool calls record tenant-scoped usage with status `succeeded` or `failed`, plus duration, session key, run/trace ID, agent ID, and user scope.
- Slash-command activation (`/` or `/use `) records a `started` event when it resolves to a skill.
- Usage writes are **internal only**. v1 intentionally has no public `POST /v1/skills/{id}/usage` endpoint, so clients cannot forge success rates.
### Persistent tables
| Table | Purpose |
|---|---|
| `skill_evolution_settings` | Per-tenant, per-skill `enabled` flag and `mode` |
| `skill_usage_metrics` | Runtime usage events and status counts |
| `skill_improvement_suggestions` | Skill-scoped suggestions with evidence and draft patches |
| `skill_versions` | Immutable applied-version records linked to changed files and the originating suggestion |
### Modes and statuses
| Field | Values | Notes |
|---|---|---|
| Settings `mode` | `suggest_only` (default), `auto_analyze` | No automatic *patching* happens in v1 — `auto_analyze` only generates suggestions automatically |
| Usage `status` | `started`, `succeeded`, `failed`, `abandoned` | Recorded per skill invocation |
| Suggestion `status` | `pending`, `approved`, `rejected`, `applied` | Lifecycle of an improvement suggestion |
### Applying a suggestion
Suggestions are reviewed and applied by admins. Applying a suggestion to a **custom** skill:
1. Copies the current skill directory forward to the next version.
2. Validates the target path (same rules as `skill_manage` companion files).
3. Runs the SKILL.md content guard scanner when SKILL.md changes.
4. Updates the active skill and records a new `skill_versions` row.
5. Writes an activity log entry.
**System/bundled skill mutation is refused** — the apply path returns `403` for any skill with `is_system = true`, before touching the filesystem.
### Admin-gated surfaces
Viewer surfaces are sanitized. Failure evidence, draft patches, actor IDs, and activity details require **admin** visibility — non-admins see only aggregate, non-sensitive data.
### HTTP API
| Method | Path | Access |
|---|---|---|
| `GET` | `/v1/skills/{id}/evolution` | Read self-evolution settings |
| `PATCH` | `/v1/skills/{id}/evolution` | Set `enabled` / `mode` (tenant admin) |
| `GET` | `/v1/skills/{id}/metrics` | Usage metrics (total / started / succeeded / failed / abandoned / success rate) |
| `GET` | `/v1/skills/{id}/activity` | Recent self-evolution activity (admin only) |
| `GET` | `/v1/skills/{id}/evolution/suggestions` | List suggestions for the skill |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{sid}/approve` | Approve a suggestion (tenant admin) |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{sid}/reject` | Reject a suggestion (tenant admin) |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{sid}/apply` | Apply an approved suggestion (tenant admin) |
The matching CLI commands are `goclaw skills evolve`, `goclaw skills metrics`, `goclaw skills suggestions`, and `goclaw skills activity`. The Dashboard surfaces all of this in the skill detail's **Evolution** tab.
---
## Common Issues
| Issue | Cause | Fix |
|---|---|---|
| Self-Evolution toggle not visible | Agent is not predefined type | Self-evolution is only for predefined agents |
| Skill not saved after postscript | User did not reply "save as skill" | Postscript requires explicit consent — reply with exact phrase |
| `skill_manage` not available to agent | `skill_evolve=false` or agent is open type | Enable `skill_evolve` in Config tab; verify agent is predefined |
| Patch fails with "not owner" | Agent trying to patch another agent's skill | Each agent can only modify skills it created |
| Patch fails with "system skill" | Attempting to modify a built-in system skill | System skills are always read-only |
| Skill content rejected | Content matched a security rule in guard.go | Remove the flagged pattern; see Layer 1 categories above |
| Companion file rejected by `skill_manage` | Path uses `..`, an absolute/Windows path, a dotfile, or a system artifact; or the file exceeds 2 MB | Use a clean relative path under the skill root and keep each text file under 2 MB |
| Cannot apply a skill suggestion | Target is a system/bundled skill, or the suggestion is not yet `approved` | System skills are read-only; approve the suggestion first, then apply |
| Skill metrics show no data | No `use_skill`/slash-command activations recorded yet, or self-evolution disabled | Usage is recorded internally as agents use the skill; enable self-evolution in the skill's Evolution tab |
---
## What's Next
- [Skills](./skills.md) — skill format, hierarchy, and hot reload
- [Predefined Agents](../core-concepts/agents-explained.md) — how predefined agents differ from open agents
---
# Docker Compose Deployment
> GoClaw ships a composable docker-compose setup: a base file, a `compose.d/` directory of always-active overlays, and a `compose.options/` directory of opt-in overlays you mix and match.
> **Auto-upgrade on start:** The Docker entrypoint runs `goclaw upgrade` automatically before starting the gateway. This applies pending database migrations so you don't need a separate upgrade step for simple deployments. For production, consider running the upgrade overlay explicitly first.
## Overview
The compose setup is modular. The base `docker-compose.yml` defines the core `goclaw` service. Active overlays live in `compose.d/` and are assembled automatically. Optional overlays in `compose.options/` can be copied into `compose.d/` to activate them.
### `compose.d/` — always-active overlays
Files in `compose.d/` are loaded automatically by `prepare-compose.sh` (sorted by filename):
```
compose.d/
00-goclaw.yml # Core service definition
11-postgres.yml # PostgreSQL 18 + pgvector
12-selfservice.yml # Web dashboard UI (nginx + React, port 3000)
13-upgrade.yml # One-shot DB migration runner
14-browser.yml # Headless Chrome sidecar (CDP, port 9222)
15-otel.yml # Jaeger for OpenTelemetry trace visualization
16-redis.yml # Redis 7 cache backend
17-sandbox.yml # Docker-in-Docker sandbox for agent code execution
18-tailscale.yml # Tailscale tsnet for secure remote access
```
### `compose.options/` — opt-in overlays
The `compose.options/` directory holds the same overlay files as reference copies. Copy the ones you want into `compose.d/` to activate them.
### `prepare-compose.sh` — build the COMPOSE_FILE
Run this script once after changing `compose.d/` to regenerate the `COMPOSE_FILE` variable in `.env`:
```bash
./prepare-compose.sh
```
The script reads all `compose.d/*.yml` files (sorted), validates the merged config with `docker compose config`, and writes the `COMPOSE_FILE` value to `.env`. Docker Compose reads `COMPOSE_FILE` automatically on every `docker compose` command.
```bash
# Flags
./prepare-compose.sh --quiet # suppress output
./prepare-compose.sh --skip-validation # skip docker compose config check
```
> **podman-compose:** `COMPOSE_FILE` is not read automatically. Run `source .env` before each `podman-compose` command.
## Overlay Reference
### `docker-compose.postgres.yml`
Starts `pgvector/pgvector:pg18` and wires `GOCLAW_POSTGRES_DSN` automatically. GoClaw waits for the health check before starting.
Environment variables (set in `.env` or shell):
| Variable | Default | Description |
|----------|---------|-------------|
| `POSTGRES_USER` | `goclaw` | Database user |
| `POSTGRES_PASSWORD` | `goclaw` | Database password — **change for production** |
| `POSTGRES_DB` | `goclaw` | Database name |
| `POSTGRES_PORT` | `5432` | Host port to expose |
### `docker-compose.selfservice.yml`
Builds the React SPA from `ui/web/` and serves it via nginx on port 3000.
| Variable | Default | Description |
|----------|---------|-------------|
| `GOCLAW_UI_PORT` | `3000` | Host port for the dashboard |
### `docker-compose.sandbox.yml`
Mounts `/var/run/docker.sock` so GoClaw can spin up isolated containers for agent shell execution. Requires the sandbox image to be built first.
> **Security note:** Mounting the Docker socket gives the container control over host Docker. Only use in trusted environments.
| Variable | Default | Description |
|----------|---------|-------------|
| `GOCLAW_SANDBOX_MODE` | `all` | `off`, `non-main`, or `all` |
| `GOCLAW_SANDBOX_IMAGE` | `goclaw-sandbox:bookworm-slim` | Image to use for sandbox containers |
| `GOCLAW_SANDBOX_WORKSPACE_ACCESS` | `rw` | `none`, `ro`, or `rw` |
| `GOCLAW_SANDBOX_SCOPE` | `session` | `session`, `agent`, or `shared` |
| `GOCLAW_SANDBOX_MEMORY_MB` | `512` | Memory limit per sandbox container |
| `GOCLAW_SANDBOX_CPUS` | `1.0` | CPU limit per sandbox container |
| `GOCLAW_SANDBOX_TIMEOUT_SEC` | `300` | Max execution time in seconds |
| `GOCLAW_SANDBOX_NETWORK` | `false` | Enable network access in sandbox |
| `DOCKER_GID` | `999` | GID of the `docker` group on the host |
### `docker-compose.browser.yml`
Starts `chromedp/headless-shell:latest` with CDP enabled on port 9222. GoClaw connects via `GOCLAW_BROWSER_REMOTE_URL=ws://chrome:9222`.
### `docker-compose.otel.yml`
Starts Jaeger (`jaegertracing/all-in-one:1.68.0`) and rebuilds GoClaw with the `ENABLE_OTEL=true` build arg to include the OTel exporter.
| Variable | Default | Description |
|----------|---------|-------------|
| `GOCLAW_TELEMETRY_ENABLED` | `true` | Enable OTel export |
| `GOCLAW_TELEMETRY_ENDPOINT` | `jaeger:4317` | OTLP gRPC endpoint |
| `GOCLAW_TELEMETRY_PROTOCOL` | `grpc` | `grpc` or `http` |
| `GOCLAW_TELEMETRY_SERVICE_NAME` | `goclaw-gateway` | Service name in traces |
### `docker-compose.tailscale.yml`
Rebuilds with `ENABLE_TSNET=true` to embed Tailscale directly in the binary (no sidecar needed).
| Variable | Required | Description |
|----------|----------|-------------|
| `GOCLAW_TSNET_AUTH_KEY` | Yes | Tailscale auth key from the admin console |
| `GOCLAW_TSNET_HOSTNAME` | No (default: `goclaw-gateway`) | Device name on the tailnet |
### `docker-compose.redis.yml`
Rebuilds GoClaw with `ENABLE_REDIS=true` and starts a Redis 7 Alpine instance with AOF persistence enabled.
| Variable | Default | Description |
|----------|---------|-------------|
| `GOCLAW_REDIS_DSN` | `redis://redis:6379/0` | Redis connection string (auto-set) |
Build arg: `ENABLE_REDIS=true` — compiles in the Redis cache backend.
Volume: `redis-data` → `/data` (AOF persistence).
### `docker-compose.upgrade.yml`
A one-shot service that runs `goclaw upgrade` and exits. Use it to apply database migrations without downtime.
```bash
# Preview what will change (dry-run)
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade --dry-run
# Apply upgrade
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade
# Check migration status
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade --status
```
---
## Build Arguments
These are compile-time flags passed during `docker build`. Each enables optional dependencies.
| Build Arg | Default | Effect |
|-----------|---------|--------|
| `ENABLE_OTEL` | `false` | OpenTelemetry span exporter |
| `ENABLE_TSNET` | `false` | Tailscale networking |
| `ENABLE_REDIS` | `false` | Redis cache backend |
| `ENABLE_SANDBOX` | `false` | Docker CLI in container (for sandbox) |
| `ENABLE_PYTHON` | `false` | Python 3 runtime for skills |
| `ENABLE_NODE` | `false` | Node.js runtime for skills |
| `ENABLE_FULL_SKILLS` | `false` | Pre-install skill dependencies (pandas, pypdf, etc.) |
| `ENABLE_CLAUDE_CLI` | `false` | Install `@anthropic-ai/claude-code` npm package |
| `VERSION` | `dev` | Semantic version string |
---
## Privilege Separation (v3)
Starting in v3, the Docker image uses **privilege separation** via `su-exec`:
```
docker-entrypoint.sh (runs as root)
├── Installs persisted apk packages (reads /app/data/.runtime/apk-packages)
├── Starts pkg-helper as root (Unix socket /tmp/pkg.sock, permissions 0660 root:goclaw)
└── su-exec goclaw → starts /app/goclaw serve (drops to non-root)
```
### pkg-helper
`pkg-helper` is a small root-privileged binary that handles system package management on behalf of the `goclaw` process. It listens on a Unix socket and accepts requests to install/uninstall Alpine packages (`apk`). The `goclaw` user cannot call `apk` directly but can request it through this helper.
Required Docker capabilities when using pkg-helper (added by default in the compose setup):
```yaml
cap_add:
- SETUID
- SETGID
- CHOWN
- DAC_OVERRIDE
```
> If you override `cap_drop: ALL` in a security-hardened compose setup, you must explicitly add these four capabilities back, or pkg-helper will fail and package installs via the admin UI will not work.
### Runtime Package Directories
On-demand packages (pip/npm) installed via the admin UI go to the data volume:
| Path | Owner | Contents |
|------|-------|---------|
| `/app/data/.runtime/pip` | `goclaw` | pip-installed Python packages |
| `/app/data/.runtime/npm-global` | `goclaw` | npm global packages |
| `/app/data/.runtime/pip-cache` | `goclaw` | pip download cache |
| `/app/data/.runtime/apk-packages` | `root:goclaw` | persisted apk package list (0640) |
These persist across container recreation because they live on the `goclaw-data` volume.
---
## Volumes
| Volume | Mount path | Contents |
|--------|-----------|----------|
| `goclaw-data` | `/app/data` | `config.json` and runtime data |
| `goclaw-workspace` | `/app/workspace` or `/app/.goclaw` | Agent workspaces |
| `goclaw-skills` | `/app/skills` | Skill files |
| `postgres-data` | `/var/lib/postgresql` | PostgreSQL data |
| `tsnet-state` | `/app/tsnet-state` | Tailscale node state |
| `redis-data` | `/data` | Redis AOF persistence |
---
## Base Container Hardening
The base `docker-compose.yml` applies these security settings to the `goclaw` service:
```yaml
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=256m
deploy:
resources:
limits:
memory: 1G
cpus: '2.0'
pids: 200
```
> The sandbox overlay (`docker-compose.sandbox.yml`) overrides `cap_drop` and `security_opt` because Docker socket access requires relaxed capabilities.
---
## Update / Upgrade Procedure
```bash
# 1. Pull latest images / rebuilt code
docker compose pull
# 2. Run DB migrations before starting new binary
docker compose run --rm upgrade
# 3. Restart the stack
docker compose up -d --build
```
> `COMPOSE_FILE` in `.env` (set by `prepare-compose.sh`) includes `13-upgrade.yml` automatically, so no explicit `-f` flags are needed.
---
## Installation Alternatives
### Binary installer (no Docker)
Download the latest binary directly:
```bash
curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install.sh | bash
# Specific version
curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install.sh | bash -s -- --version v1.19.1
# Custom directory
curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install.sh | bash -s -- --dir /opt/goclaw
```
Supports Linux and macOS (amd64 and arm64).
### Interactive Docker setup
The setup script generates `.env` and builds the right compose command:
```bash
./scripts/setup-docker.sh # Interactive mode
./scripts/setup-docker.sh --variant full --with-ui # Non-interactive
```
Variants: `alpine` (base), `node`, `python`, `full`. Add `--with-ui` for the dashboard, `--dev` for development mode with live reload.
---
## Pre-built Docker Images
Official multi-arch images (amd64 + arm64) are published on every release to both registries:
| Registry | Gateway | Web Dashboard |
|----------|---------|--------------|
| Docker Hub | `digitop/goclaw` | `digitop/goclaw-web` |
| GHCR | `ghcr.io/nextlevelbuilder/goclaw` | `ghcr.io/nextlevelbuilder/goclaw-web` |
### Tag variants
Images are split into **runtime variants** (what's pre-installed) and **build-tag variants** (compiled-in features):
**Runtime variants:**
| Tag | Node.js | Python | Skill deps | Use case |
|-----|---------|--------|------------|----------|
| `latest` / `vX.Y.Z` | — | — | — | Minimal base (~50 MB) |
| `node` / `vX.Y.Z-node` | ✓ | — | — | JS/TS skills |
| `python` / `vX.Y.Z-python` | — | ✓ | — | Python skills |
| `full` / `vX.Y.Z-full` | ✓ | ✓ | ✓ | All skill dependencies pre-installed |
**Build-tag variants:**
| Tag | OTel | Tailscale | Redis | Use case |
|-----|------|-----------|-------|----------|
| `otel` / `vX.Y.Z-otel` | ✓ | — | — | OpenTelemetry tracing |
| `tsnet` / `vX.Y.Z-tsnet` | — | ✓ | — | Tailscale remote access |
| `redis` / `vX.Y.Z-redis` | — | — | ✓ | Redis caching |
> **Tip:** Runtime and build-tag variants are independent. If you need Python + OTel, build locally with `ENABLE_PYTHON=true` and `ENABLE_OTEL=true`.
Pull example:
```bash
# Latest minimal
docker pull digitop/goclaw:latest
# With Python runtime
docker pull digitop/goclaw:python
# Full runtime (Node + Python + all deps)
docker pull digitop/goclaw:full
# With OTel tracing
docker pull ghcr.io/nextlevelbuilder/goclaw:otel
```
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| `goclaw` exits immediately on start | PostgreSQL not ready | The postgres overlay adds a health check dependency; ensure you include it |
| Sandbox containers not starting | Docker socket not mounted or wrong GID | Add the sandbox overlay and set `DOCKER_GID` to match `stat -c %g /var/run/docker.sock` |
| Dashboard returns 502 | `goclaw` service not healthy yet | Check `docker compose logs goclaw`; dashboard depends on `goclaw` being up |
| OTel traces not appearing in Jaeger | Binary built without `ENABLE_OTEL=true` | Add `--build` flag when using the otel overlay; it rebuilds with the build arg |
| Port 5432 already in use | Local Postgres running | Set `POSTGRES_PORT=5433` in `.env` |
| `database schema is outdated` | Migrations not applied after update | Add `GOCLAW_AUTO_UPGRADE=true` to `.env` **file** (not as shell prefix — compose reads from `env_file`), or run the upgrade overlay before starting |
| `network goclaw-net … incorrect label` | A `goclaw-net` Docker network already exists with conflicting labels | Run `docker network rm goclaw-net` then retry — Compose creates its own `goclaw-net` network automatically |
---
## What's Next
- [Database Setup](/deploy-database) — manual PostgreSQL setup and migrations
- [Security Hardening](/deploy-security) — five-layer security overview
- [Observability](/deploy-observability) — OpenTelemetry and Jaeger configuration
- [Tailscale](/deploy-tailscale) — secure remote access via Tailscale
---
# Database Setup
> GoClaw requires **PostgreSQL 15+** with `pgvector` for multi-tenant storage, semantic memory search, and Knowledge Vault features. A **SQLite** backend is also available for desktop (single-user) deployments with reduced feature set — see [SQLite vs PostgreSQL](#sqlite-vs-postgresql) below.
## Overview
All persistent state lives in PostgreSQL: agents, sessions, memory, traces, skills, cron jobs, channel configs, Knowledge Vault documents, and episodic summaries. The schema is managed via numbered migration files in `migrations/`. Two extensions are required: `pgcrypto` (UUID generation) and `vector` (semantic memory search via pgvector).
## Manual Setup
### 1. Install PostgreSQL 15+ with pgvector
On Ubuntu/Debian:
```bash
# Install PostgreSQL
sudo apt install postgresql postgresql-contrib
# Install pgvector (choose your PG version)
sudo apt install postgresql-16-pgvector
```
Using the official pgvector Docker image (recommended):
```bash
docker run -d \
--name goclaw-postgres \
-e POSTGRES_USER=goclaw \
-e POSTGRES_PASSWORD=your-secure-password \
-e POSTGRES_DB=goclaw \
-p 5432:5432 \
pgvector/pgvector:pg18
```
### 2. Create the database and enable extensions
```sql
-- Connect as superuser
CREATE DATABASE goclaw;
\c goclaw
-- Required extensions (both are enabled by migration 000001 automatically)
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "vector";
```
> The `vector` extension provides HNSW vector indexes used for memory similarity search. `pgcrypto` provides UUID v7 generation via `gen_random_bytes()`.
### 3. Set the connection string
Add to your `.env` file or shell environment:
```bash
GOCLAW_POSTGRES_DSN=postgres://goclaw:your-secure-password@localhost:5432/goclaw?sslmode=disable
```
For production with TLS:
```bash
GOCLAW_POSTGRES_DSN=postgres://goclaw:password@db.example.com:5432/goclaw?sslmode=require
```
The DSN is a standard `lib/pq` / `pgx` connection string. All standard PostgreSQL parameters are supported (`connect_timeout`, `pool_max_conns`, etc.).
---
## Run Migrations
GoClaw uses [golang-migrate](https://github.com/golang-migrate/migrate) with numbered SQL files.
```bash
# Apply all pending migrations
./goclaw migrate up
# Check current migration version
./goclaw migrate status
# Roll back one step
./goclaw migrate down
# Roll back to a specific version
./goclaw migrate down 3
```
With Docker (using the upgrade overlay):
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade
```
### Migration files
| File | What it creates |
|------|----------------|
| `000001_init_schema` | All core tables: agents, sessions, memory, traces, spans, skills, cron, pairing, MCP, custom tools, channels |
| `000002_agent_links` | `agent_links` table for agent-to-agent delegation |
| `000003_agent_teams` | Team and task tables for multi-agent teams |
| `000004_teams_v2` | Team metadata and task status improvements |
| `000005_phase4` | Additional phase-4 schema changes |
| `000006_builtin_tools` | Built-in tool configuration storage |
| `000007_team_metadata` | Team metadata JSONB fields |
| `000008_team_tasks_user_scope` | Per-user task scoping |
| `000009_add_quota_index` | Partial index for quota checker performance |
| `000010_agents_md_v2` | Agent metadata v2 schema |
| `000011_session_profile_metadata` | JSONB `metadata` columns on sessions, profiles, pairing |
| `000012_channel_pending_messages` | `channel_pending_messages` table for group chat history buffer |
| `000013_knowledge_graph` | `kg_entities`, `kg_relations` tables for semantic entity storage |
| `000014_channel_contacts` | `channel_contacts` table — global contact directory from channels |
| `000015_agent_budget` | `budget_monthly_cents` on agents; `activity_logs` audit trail |
| `000016_usage_snapshots` | `usage_snapshots` table — hourly token/cost aggregation |
| `000017_system_skills` | `is_system`, `deps`, `enabled` columns on skills |
| `000018_team_tasks_workspace_followup` | Team workspace files, file versions, comments; task events and comments |
| `000019_team_id_columns` | `team_id` FK on memory, KG, traces, spans, cron, sessions (9 tables) |
| `000020_secure_cli_and_api_keys` | `secure_cli_binaries` for credentialed exec; `api_keys` for fine-grained auth |
| `000021_paired_devices_expiry` | `expires_at` on paired devices; `confidence_score` on team tasks, messages, comments |
| `000022`–`000036` | Heartbeats, agent hard-delete, team attachments refactor, KG semantic search, tenant foundation, subagent tasks, CLI grants, and more — see [Database Schema → Migration History](/database-schema) |
| `000037_v3_memory_evolution` | **v3** — `episodic_summaries`, `agent_evolution_metrics`, `agent_evolution_suggestions`; KG temporal columns; 12 agent config fields promoted from `other_config` JSONB |
| `000038_vault_tables` | **v3** — `vault_documents`, `vault_links`, `vault_versions` for Knowledge Vault |
| `000039_episodic_summaries` | Clears stale `agent_links` data |
| `000040_episodic_search_index` | Adds `search_vector` generated FTS column + HNSW index to `episodic_summaries` |
| `000041_episodic_promoted` | Adds `promoted_at` column for long-term memory promotion pipeline |
| `000042_vault_tsv_summary` | Adds `summary` column to `vault_documents`; rebuilds FTS to include summary |
| `000043_vault_team_custom_scope` | Adds `team_id`, `custom_scope` to `vault_documents`; team-safe unique constraint; scope-fix trigger; `custom_scope` on 9 other tables |
| `000044_seed_agents_core_task_files` | Seeds `AGENTS_CORE.md` and `AGENTS_TASK.md` context files; removes deprecated `AGENTS_MINIMAL.md` |
> **Data hooks:** GoClaw tracks post-migration Go transforms in a separate `data_migrations` table. Run `./goclaw upgrade --status` to see both SQL migration version and pending data hooks.
> Migrations `000045`–`000080` continue the schema: model fallback config, REST endpoint catalog, Bitrix24 portal OAuth state (`bitrix_portals`), server-side browser cookie sync (`browser_cookies`), the usage-cap pricing and enforcement tables (`usage_pricing_catalog`, `usage_pricing_overrides`, `usage_cap_policies`, `usage_cap_counters`, `usage_cap_reservations`, `usage_cap_events`), the agent-budget bridge, typed secure-CLI credentials, archived run timelines (`run_timeline_items`), per-channel context capabilities (scoped MCP/secure-CLI grants and credentials), passive channel memory extraction, per-agent secure-CLI credentials, skill self-evolution (`skill_usage_metrics`, `skill_improvement_suggestions`, `skill_versions`), and usage event analytics (`usage_events`, `usage_event_rollups`) — see [Database Schema → Migration History](/database-schema).
Run `./goclaw migrate status` after deployment to confirm the current schema is version **80**.
---
## SQLite vs PostgreSQL
GoClaw v3 supports two database backends:
| Feature | PostgreSQL | SQLite (desktop) |
|---------|-----------|-----------------|
| Full schema (all 80 migrations) | Yes | Yes |
| Vector similarity search (HNSW) | Yes — pgvector | No |
| Episodic summaries vector search | Yes | Keyword (FTS) only |
| Knowledge Vault auto-linking | Yes — similarity threshold 0.7 | No (summarise only) |
| `kg_entities` semantic search | Yes | No |
| Multi-tenant isolation | Yes | Single-tenant only |
| Connection pooling | Yes — pgx/v5, 25 max | N/A (embedded) |
Use PostgreSQL for all production and multi-user deployments. SQLite is supported only in the desktop (single-binary) build and lacks vector operations.
---
## Key Tables
| Table | Purpose |
|-------|---------|
| `agents` | Agent definitions, model config, tool config |
| `sessions` | Conversation history, token counts per session |
| `traces` / `spans` | LLM call tracing, token usage, costs |
| `memory_chunks` | Semantic memory (pgvector HNSW index, `vector(1536)`) |
| `memory_documents` | Memory document metadata |
| `embedding_cache` | Cached embeddings keyed by content hash + model |
| `llm_providers` | LLM provider configs (API keys encrypted AES-256-GCM) |
| `mcp_servers` | External MCP server connections |
| `cron_jobs` / `cron_run_logs` | Scheduled tasks and run history |
| `skills` | Skill files with BM25 + vector search |
| `channel_instances` | Messaging channel configs (Telegram, Discord, etc.) |
| `activity_logs` | Audit trail — admin actions, config changes, security events |
| `usage_snapshots` | Hourly aggregated token counts and costs per agent/user |
| `kg_entities` / `kg_relations` | Knowledge graph — semantic entities and relationships (v3: temporal validity via `valid_from`/`valid_until`) |
| `channel_contacts` | Unified contact directory synced from all channels |
| `channel_pending_messages` | Pending group messages buffer for batch processing |
| `api_keys` | Scoped API keys with SHA-256 hash lookup and revocation |
| `episodic_summaries` | **v3** — Tier 2 memory: compressed session summaries with FTS and vector search |
| `agent_evolution_metrics` | **v3** — Self-evolution Stage 1: raw metric observations per session |
| `agent_evolution_suggestions` | **v3** — Self-evolution Stage 2: proposed behavioural changes for review |
| `vault_documents` | **v3** — Knowledge Vault document registry (path, hash, embedding, FTS) |
| `vault_links` | **v3** — Bidirectional wikilinks between vault documents |
| `subagent_tasks` | Subagent task persistence for lifecycle tracking, cost attribution |
---
## Backup and Restore
### Backup
```bash
# Full database dump (recommended — includes schema + data)
pg_dump -h localhost -U goclaw -d goclaw -Fc -f goclaw-backup.dump
# Schema only (for inspecting structure)
pg_dump -h localhost -U goclaw -d goclaw --schema-only -f goclaw-schema.sql
# Exclude large tables if needed (e.g., skip spans for smaller backups)
pg_dump -h localhost -U goclaw -d goclaw -Fc \
--exclude-table=spans \
-f goclaw-backup-no-spans.dump
```
### Restore
```bash
# Restore to a fresh database
createdb -h localhost -U postgres goclaw_restore
pg_restore -h localhost -U goclaw -d goclaw_restore goclaw-backup.dump
```
### Docker volume backup
```bash
# Backup the postgres-data volume
docker run --rm \
-v goclaw_postgres-data:/data \
-v $(pwd):/backup \
alpine tar czf /backup/postgres-data-$(date +%Y%m%d).tar.gz -C /data .
```
---
## Performance
### Connection pooling
GoClaw uses `pgx/v5` with `database/sql`. The connection pool is hard-coded to **25 max open / 10 max idle** connections. For high-concurrency deployments, ensure your PostgreSQL `max_connections` accommodates this. You can also set pool parameters in the DSN:
```bash
GOCLAW_POSTGRES_DSN=postgres://goclaw:password@localhost:5432/goclaw?sslmode=disable&pool_max_conns=20
```
Or use PgBouncer in front of PostgreSQL for connection pooling at scale.
### Key indexes
The schema includes these performance-critical indexes out of the box:
| Index | Table | Purpose |
|-------|-------|---------|
| `idx_traces_quota` | `traces` | Per-user quota window queries (partial, top-level only) |
| `idx_mem_vec` | `memory_chunks` | HNSW cosine similarity search (`vector_cosine_ops`) |
| `idx_mem_tsv` | `memory_chunks` | Full-text BM25 search via `tsvector` GIN index |
| `idx_traces_user_time` | `traces` | Usage queries by user + time |
| `idx_sessions_updated` | `sessions` | Listing recent sessions |
The `idx_traces_quota` index is added as `CONCURRENTLY` in migration `000009` — it can be created without locking the table on live systems.
### Disk growth
The `spans` table grows quickly under heavy use (one row per LLM call span). Consider periodic pruning:
```sql
-- Delete spans older than 30 days
DELETE FROM spans WHERE created_at < NOW() - INTERVAL '30 days';
-- Delete traces older than 90 days (cascades to spans)
DELETE FROM traces WHERE created_at < NOW() - INTERVAL '90 days';
VACUUM ANALYZE traces, spans;
```
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| `extension "vector" does not exist` | pgvector not installed | Install `postgresql-XX-pgvector` or use the `pgvector/pgvector` Docker image |
| `migrate up` fails on first run | Extensions not enabled | Ensure the DB user has `SUPERUSER` or `CREATE EXTENSION` privilege |
| Connection refused | Wrong host/port in DSN | Check `GOCLAW_POSTGRES_DSN`; verify PostgreSQL is running |
| Memory search returns no results | Embedding model dimension mismatch | Schema uses `vector(1536)` — ensure your embedding model outputs 1536 dims |
| High disk usage | `spans` table unbounded growth | Schedule periodic `DELETE` + `VACUUM` on `spans` and `traces` |
---
## What's Next
- [Docker Compose](/deploy-docker-compose) — compose-based deployment with the postgres overlay
- [Security Hardening](/deploy-security) — AES-256-GCM encryption for secrets in the database
- [Observability](/deploy-observability) — querying traces and spans for LLM cost monitoring
---
# Security Hardening
> GoClaw uses five independent defense layers — transport, input, tools, output, and isolation — so a bypass of one layer doesn't compromise the rest.
## Overview
Each layer operates independently. Together they form a defense-in-depth architecture covering the full request lifecycle from incoming WebSocket connection to agent tool execution output.
```mermaid
flowchart TD
REQ["Incoming Request"] --> L1["Layer 1: Transport\nCORS · size limits · timing-safe auth · rate limiting"]
L1 --> L2["Layer 2: Input\nInjection detection · message truncation · ILIKE escape"]
L2 --> L3["Layer 3: Tools\nShell deny patterns · path traversal · SSRF · exec approval · file serving protection"]
L3 --> L4["Layer 4: Output\nCredential scrubbing · web content tagging · MCP content tagging"]
L4 --> L5["Layer 5: Isolation\nPer-user workspace · Docker sandbox · privilege separation"]
```
## Layer 2: Input — Injection Detection
The input guard scans every user message for 6 prompt injection patterns before it reaches the LLM.
| Pattern ID | Detects |
|-----------|---------|
| `ignore_instructions` | "ignore all previous instructions" |
| `role_override` | "you are now…", "pretend you are…" |
| `system_tags` | ``, `[SYSTEM]`, `[INST]`, `<>` |
| `instruction_injection` | "new instructions:", "override:", "system prompt:" |
| `null_bytes` | Null characters `\x00` (obfuscation attempts) |
| `delimiter_escape` | "end of system", ``, `` |
**Configurable action** via `gateway.injection_action`:
| Value | Behavior |
|-------|----------|
| `"off"` | Disable detection entirely |
| `"log"` | Log at info level, continue |
| `"warn"` (default) | Log at warning level, continue |
| `"block"` | Log warning, return error, stop processing |
For public-facing deployments or shared multi-user agents, set `"block"`.
**Message truncation:** Messages exceeding `gateway.max_message_chars` (default 32,000) are truncated — not rejected — and the LLM is notified of the truncation.
**ILIKE ESCAPE:** All database ILIKE queries (search/filter operations) escape `%`, `_`, and `\` characters before execution, preventing SQL wildcard injection attacks.
---
## Layer 3: Tool Security
Protects against dangerous command execution, unauthorized file access, and server-side request forgery.
### Shell deny groups
15 categories of commands are blocked by default. All groups are **on (denied)** out of the box. Per-agent overrides are possible via `shell_deny_groups` in agent config.
| # | Group | Examples |
|---|-------|----------|
| 1 | `destructive_ops` | `rm -rf /`, `dd if=`, `mkfs`, `reboot`, `shutdown` |
| 2 | `data_exfiltration` | `curl \| sh`, localhost access, DNS queries |
| 3 | `reverse_shell` | `nc -e`, `socat`, Python/Node socket |
| 4 | `code_injection` | `eval $()`, `base64 -d \| sh` |
| 5 | `privilege_escalation` | `sudo`, `su -`, `nsenter`, `mount`, `setcap`, `halt`, `doas`, `pkexec`, `runuser` |
| 6 | `dangerous_paths` | `chmod`/`chown` on `/` paths |
| 7 | `env_injection` | `LD_PRELOAD=`, `DYLD_INSERT_LIBRARIES=` |
| 8 | `container_escape` | `docker.sock`, `/proc/sys/`, `/sys/kernel/` |
| 9 | `crypto_mining` | `xmrig`, `cpuminer`, stratum URLs |
| 10 | `filter_bypass` | `sed /e`, `git --upload-pack=`, CVE mitigations |
| 11 | `network_recon` | `nmap`, `ssh@`, `ngrok`, `chisel` |
| 12 | `package_install` | `pip install`, `npm i`, `apk add`, `yarn` |
| 13 | `persistence` | `crontab`, `.bashrc`, tee shell init |
| 14 | `process_control` | `kill -9`, `killall`, `pkill` |
| 15 | `env_dump` | `env`, `printenv`, `GOCLAW_*` vars, `/proc/*/environ` |
To allow a specific group for one agent, set it to `false` in the agent's config:
```json
{
"agents": {
"list": {
"devops-bot": {
"shell_deny_groups": {
"package_install": false,
"process_control": false
}
}
}
}
}
```
### Global shell deny-groups — runtime toggle
`config.tools.shellDenyGroups` is a `map[string]bool` that lets you enable or disable deny-groups globally without restarting the gateway. Changes take effect immediately via `bus.TopicConfigChanged` live-reload. The reload clones the config snapshot before applying it, so group *disables* persist correctly across reload, and it also reloads provider-level shell-deny policies (Claude CLI / ACP), not just the global exec tool.
```json
{
"tools": {
"shellDenyGroups": {
"package_install": false,
"env_dump": false
}
}
}
```
**Precedence:** per-agent `shell_deny_groups` always wins over the global setting. The global value only applies when a given group is not explicitly set in the agent's own config. This lets you relax a group gateway-wide while still locking it down for specific agents.
See [`reference/config-reference.md`](../reference/config-reference.md) for the full `tools.shellDenyGroups` field reference.
### Path traversal prevention
`resolvePath()` applies `filepath.Clean()` then `HasPrefix()` to ensure all file paths stay within the agent's workspace. With `restrict_to_workspace: true` (the default on agents), any path outside the workspace is blocked.
All four filesystem tools (`read_file`, `write_file`, `list_files`, `edit`) implement the `PathDenyable` interface. The agent loop calls `DenyPaths(".goclaw")` at startup — agents cannot read GoClaw's internal data directory. The `list_files` tool filters denied paths from directory listings entirely, so agents never see them.
**venv interpreter exemption.** The GoClaw-managed Python interpreter is exempt from the `.goclaw/` deny so agents can run it directly. GoClaw resolves `/.goclaw/venv/bin/python3` (following symlinks) once at startup and exempts the *resolved* interpreter directory; if no venv is present it falls back silently. This is the only path under `.goclaw/` that exec can reach.
### File serving path traversal protection
The file serving endpoint (`/v1/files/...`) validates all requested paths to prevent directory traversal attacks. Any path containing `../` sequences or resolving outside the permitted base directory is rejected with a 400 error.
### SSRF protection (3-step validation)
Applied to all outbound URL fetches by the `web_fetch` tool:
```mermaid
flowchart TD
U["URL to fetch"] --> S1["Step 1: Blocked hostnames\nlocalhost · *.local · *.internal\nmetadata.google.internal"]
S1 --> S2["Step 2: Private IP ranges\n10.0.0.0/8 · 172.16.0.0/12\n192.168.0.0/16 · 127.0.0.0/8\n169.254.0.0/16 · IPv6 loopback"]
S2 --> S3["Step 3: DNS pinning\nResolve domain · check every resolved IP\nApplied to redirect targets too"]
S3 --> A["Allow request"]
```
### Credentialed exec (Direct Exec Mode)
For tools that need credentials (e.g., `gh`, `aws`), GoClaw uses direct process execution instead of a shell — eliminating shell injection entirely.
4-layer defense:
1. **No shell** — `exec.CommandContext(binary, args...)`, never `sh -c`
2. **Path verification** — binary resolved to absolute path via `exec.LookPath()`, matched against config
3. **Deny patterns** — per-binary regex deny lists on arguments (`deny_args`) and verbose flags (`deny_verbose`)
4. **Output scrubbing** — credentials registered at runtime are scrubbed from stdout/stderr
Shell metacharacters (`;`, `|`, `&`, `$()`, backticks) are detected and rejected before execution.
### Exec grant enforcement
Agent-level grant enforcement runs **before** any process spawn, blocking ungranted agents from executing registered binaries:
| Control | Detail |
|---------|--------|
| **Grant lookup** | `store.SecureCLIStore.IsRegisteredBinary()` checks the `secure_cli_agent_grants` table. Non-global binaries require a row for the calling agent. |
| **Fail-closed** | If the grant lookup errors (DB down, timeout), exec is denied with a retry message. Per-lookup timeout: 2 seconds. |
| **Env scrubbing** | When a command bypasses the credentialed path (e.g., via adversarial use of the `exec` tool), the child process environment is scrubbed of all credential keys before spawn — static deny list plus dynamic keys from every registered binary in the tenant. |
| **Wrapper unwrap** | Shell wrappers (`sh -c`, `bash -c`, etc.) that attempt to evade binary path matching are blocked. GoClaw checks up to 3 levels of nesting; deeper chains are rejected as adversarial. |
| **Subagent wiring** | Subagent `ExecTool`s use the same `SecureCLIStore` via `buildSubagentToolsRegistry`. Parent agents cannot bypass the gate by delegating exec to spawned subagents. |
Security log events emitted by the grant gate:
| Event | Meaning |
|-------|---------|
| `security.credentialed_binary_denied` | Agent attempted to run a binary it has no grant for |
| `security.credentialed_binary_gate_error` | Grant lookup failed (DB error); exec denied |
| `security.credentialed_binary_wrapper_too_deep` | Shell wrapper nesting exceeded 3 levels; rejected as adversarial |
All three events include: `binary`, `wrapper`, `agent_id`, `tenant_id`, and `command` prefix fields.
### Shell output limit
Host-executed commands have stdout and stderr capped at **1 MB** each. If a command exceeds this limit, output is truncated with a flag to prevent further writes. Sandboxed execution uses Docker container limits instead.
### XML parsing (XXE prevention)
GoClaw replaced the stdlib `xml.etree.ElementTree` XML parser with `defusedxml` in all XML processing paths. `defusedxml` blocks XML eXternal Entity (XXE) attacks — where a crafted XML payload references external entities to read local files or trigger SSRF. This applies to any agent tool or skill that parses XML input.
### Exec approval
See [Exec Approval](/exec-approval) for the full interactive approval flow. At minimum, enable `ask: "on-miss"` to prompt before network and infrastructure tools run:
```json
{
"tools": {
"execApproval": {
"security": "full",
"ask": "on-miss"
}
}
}
```
---
## Layer 4: Output Security
Prevents secrets from leaking back through tool output or LLM responses.
### Credential scrubbing (automatic)
All tool output passes through a regex scrubber that redacts known secret formats. Replaced with `[REDACTED]`:
| Pattern | Examples |
|---------|----------|
| OpenAI keys | `sk-...` |
| Anthropic keys | `sk-ant-...` |
| GitHub tokens | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` |
| AWS access keys | `AKIA...` |
| Connection strings | `postgres://...`, `mysql://...` |
| Env var patterns | `KEY=...`, `SECRET=...`, `DSN=...` |
| Long hex strings | 64+ character hex sequences |
| DSN / database URLs | `DSN=...`, `DATABASE_URL=...`, `REDIS_URL=...`, `MONGO_URI=...` |
| Generic key-value | `api_key=...`, `token=...`, `secret=...`, `bearer=...` (case-insensitive) |
| Runtime env vars | `VIRTUAL_*=...` patterns |
13 regex patterns in total cover all major secret formats.
Scrubbing is enabled by default. To disable (not recommended):
```json
{ "tools": { "scrub_credentials": false } }
```
You can also register runtime values for dynamic scrubbing (e.g., server IPs discovered at runtime) via `AddDynamicScrubValues()` in custom tool integrations.
### Web content tagging
Content fetched from external URLs is wrapped:
```
<<>>
[fetched content here]
<<>>
```
This signals to the LLM that the content is untrusted and should not be treated as instructions.
The content markers are protected against Unicode homoglyph spoofing — GoClaw sanitizes lookalike characters (e.g., Cyrillic `а` vs Latin `a`) to prevent external content from forging the boundary markers.
### MCP content tagging
Tool results from MCP servers are wrapped with the same untrusted content markers:
```
<<>> (MCP server: my-server, tool: search)
[tool result here]
<<>>
```
The header identifies the server and tool name. The footer warns the LLM not to follow instructions from the content. Marker breakout attempts are sanitized.
---
## Layer 5: Isolation
### Per-user workspace isolation
Every user gets a sandboxed directory. Two levels:
| Level | Directory pattern |
|-------|-----------------|
| Per-agent | `~/.goclaw/{agent-key}-workspace/` |
| Per-user | `{agent-workspace}/user_{sanitized_user_id}/` |
User IDs are sanitized — characters outside `[a-zA-Z0-9_-]` become underscores. Example: `group:telegram:-1001234` → `group_telegram_-1001234`.
### Docker entrypoint — privilege separation
GoClaw's Docker container uses a three-phase privilege model:
**Phase 1: Root (`docker-entrypoint.sh`)**
- Re-installs persisted system packages from `/app/data/.runtime/apk-packages`
- Starts `pkg-helper` (root-privileged service listening on Unix socket `/tmp/pkg.sock`, mode 0660, group `goclaw`)
- Sets up Python and Node.js runtime directories
**Phase 2: Drop to `goclaw` user (`su-exec`)**
- Main app runs as `goclaw` (UID 1000) via `su-exec goclaw /app/goclaw`
- All agent operations execute in this context
- System package requests are delegated to `pkg-helper` via Unix socket
**Phase 3: Optional sandbox (per-agent)**
- Shell execution can be sandboxed in Docker containers (configurable)
### pkg-helper — root service
`pkg-helper` runs as root on a Unix socket (`/tmp/pkg.sock`, 0660 `root:goclaw`). It accepts only `apk add` / `apk del` requests from the `goclaw` user. Required Docker Compose capabilities:
| Capability | Purpose |
|-----------|---------|
| `SETUID` | `su-exec` privilege drop |
| `SETGID` | Group membership for socket |
| `CHOWN` | Runtime directory ownership setup |
| `DAC_OVERRIDE` | pkg-helper socket access |
All other capabilities are dropped (`cap_drop: ALL`). The full compose security config:
```yaml
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- CHOWN
- DAC_OVERRIDE
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=256m,noexec,nosuid
```
### Runtime directories
Packages and runtime data are stored under `/app/data/.runtime`, which survives container recreation:
| Path | Owner | Purpose |
|------|-------|---------|
| `/app/data/.runtime/apk-packages` | 0666 | Persisted apk package list |
| `/app/data/.runtime/pip` | goclaw | Python packages (`$PIP_TARGET`) |
| `/app/data/.runtime/npm-global` | goclaw | npm packages (`$NPM_CONFIG_PREFIX`) |
| `/tmp/pkg.sock` | root:goclaw 0660 | pkg-helper Unix socket |
### Docker sandbox
For agent shell execution, enable the Docker sandbox to run commands in an isolated container:
```bash
# Build the sandbox image
docker build -t goclaw-sandbox:bookworm-slim -f Dockerfile.sandbox .
```
```json
{
"sandbox": {
"mode": "all",
"image": "goclaw-sandbox:bookworm-slim",
"workspace_access": "rw",
"scope": "session"
}
}
```
Container hardening applied automatically:
| Setting | Value |
|---------|-------|
| Root filesystem | Read-only (`--read-only`) |
| Capabilities | All dropped (`--cap-drop ALL`) |
| New privileges | Disabled (`--security-opt no-new-privileges`) |
| Memory limit | 512 MB |
| CPU limit | 1.0 |
| Network | Disabled (`--network none`) |
| Max output | 1 MB |
| Timeout | 300 seconds |
Sandbox modes: `off` (direct host exec), `non-main` (sandbox all except the main agent), `all` (sandbox every agent).
---
## Session IDOR Fix
All five `chat.*` WebSocket methods (`chat.send`, `chat.abort`, `chat.stop`, `chat.stopall`, `chat.reset`) verify that the caller owns the session before acting on it. The `requireSessionOwner` helper in `internal/gateway/methods/access.go` performs this check. Non-admin users supplying a `sessionKey` that belongs to another user receive an authorization error — the operation is never executed.
---
## Pairing Auth Hardening
Browser device pairing is fail-closed:
| Control | Detail |
|---------|--------|
| Fail-closed | `IsPaired()` check blocks unpaired sessions — no fallback to open access |
| Rate limiting | Max 3 pending pairing requests per account; prevents enumeration spam |
| TTL enforcement | Pairing codes expire after 60 minutes; paired device tokens expire after 30 days |
| Approval flow | Requires WebSocket `device.pair.approve` from an authenticated admin session |
---
## Encryption
Secrets stored in PostgreSQL are encrypted with AES-256-GCM:
| What | Table | Column |
|------|-------|--------|
| LLM provider API keys | `llm_providers` | `api_key` |
| MCP server API keys | `mcp_servers` | `api_key` |
| Custom tool env vars | `custom_tools` | `env` |
| Channel credentials | `channel_instances` | `credentials` |
| Webhook secrets | `webhooks` | `secret_hash`, `secret_enc` |
| Workstation credentials | `workstations` | `metadata` |
Set the encryption key before first run:
```bash
# Generate a strong key (base64, 44 chars = 32 raw bytes)
openssl rand -base64 32
# Add to .env
GOCLAW_ENCRYPTION_KEY=your-44-char-base64-key
```
The key is accepted in three formats: base64-encoded (44 chars, output of `openssl rand -base64 32`), hex-encoded (64 chars, output of `openssl rand -hex 32`), or raw 32 bytes. All three resolve to the same 32-byte AES key; use base64 as the canonical form to match the environment-variables reference.
Format stored: `"aes-gcm:" + base64(12-byte nonce + ciphertext + GCM tag)`. Values without the prefix are returned as plaintext for migration compatibility.
> **Must match across replicas.** In a clustered deployment, every gateway instance must use the same `GOCLAW_ENCRYPTION_KEY`. Rotating the key requires re-encrypting all stored secrets before restarting.
Credentialed-CLI env vars are also AES-256-GCM encrypted: `secure_cli_binaries`, `secure_cli_agent_grants`, `secure_cli_user_credentials`, and `secure_cli_agent_credentials` all store secrets in an `encrypted_env` column. Each entry carries a visibility `kind` — `sensitive` entries are masked in normal API/UI responses and only returned via the audited `env:reveal` flow; `value` entries (e.g. region or profile names) are returned to admins for operational review.
---
## Credential Adapter Security Model
[Typed credential adapters](/cli-credentials) are the **system-trusted** path for injecting auth material into spawned CLI subprocesses (`git clone`, `git push`, …). This is a second trust boundary, distinct from the user-paste env denylist — not a replacement for it.
| Path | Trust boundary |
|------|----------------|
| User-pasted env vars (`env` credential type) | First line of defense — `ValidateGrantEnvVars` rejects `GIT_SSH_COMMAND`, `LD_PRELOAD`, `PATH`, and the rest of the denylist |
| System-injected adapter env (e.g. `git` adapter) | Second, audit-trailed line — bypasses the denylist by design and emits one audit event per injection |
A typo in the adapter name falls back to passthrough (the legacy denylist-only behavior) — there is **no silent bypass**.
### Effective credential precedence
When an adapter resolves which credential to inject, it picks the **first** match in this order: **user override → channel/context credential → agent credential → binary-level env defaults**. Agent credentials (`secure_cli_agent_credentials`) are the default trust boundary for git; a user override or a channel/context credential takes precedence when present. The `credential_source` audit field records which layer was used.
For the PAT path, the adapter synthesizes an `http..extraheader` config entry with `Authorization: Basic base64("x-access-token:")`. The raw token, the base64 payload, and the full header are all registered with the scrubber. SSH private keys are validated **twice** at save time — first with Go's SSH parser, then with OpenSSH (`ssh-keygen -y -f`) when available — so keys that would fail later with OpenSSH diagnostics are rejected up front.
### Audit event: `security.system_env_injection`
Every adapter injection emits **exactly one** structured `slog.Warn` line. The plaintext hostname is intentionally **never logged** — keeping audit logs PII-safe inside regulated tenants.
| Field | Notes |
|-------|-------|
| `msg` | always `security.system_env_injection` |
| `adapter` | e.g. `git`, `passthrough` |
| `binary` | binary name (`git`, …) |
| `user_id` | tenant user UUID (empty in global-only contexts) |
| `env_keys` | sorted env-var **names** only — never values |
| `argv_prefix_len` | number of argv elements prepended, not their content |
| `host_scope_hash` | first 8 hex chars of `SHA-256(normalized host_scope)`, or `"none"` |
| `credential_source` | which precedence layer supplied the credential: `user`, `context`, `agent`, or empty when no scoped credential row was selected |
There is **no dedicated audit table** in v1 — the line routes through `slog` to stderr → systemd/journald or Docker logs. To grep for activity against a specific host, pre-compute its hash:
```sh
echo -n "github.com" | sha256sum | cut -c1-8
```
### Output scrubbing for credentialed exec
Output of credentialed commands passes through the same regex scrubber as all tool output, plus credentials registered at runtime for the binary are scrubbed from stdout/stderr before the result reaches the LLM. Secrets injected through an adapter never round-trip back into the conversation.
### SSH TOFU MITM caveat
The git adapter's SSH path sets `StrictHostKeyChecking=accept-new`, which accepts an unknown host key on first contact. A network attacker positioned between GoClaw and the git host can capture that **first** SSH session. Pre-seed `known_hosts` at deploy time to close the window — after that, `accept-new` enforces match-or-fail:
```sh
ssh-keyscan github.com >> ~/.ssh/known_hosts
ssh-keyscan -p 22 gitea.internal >> ~/.ssh/known_hosts
```
### Ephemeral credential tmpfiles
The SSH adapter writes the PEM key to a `0600`-mode tmpfile in `os.TempDir()` (prefix `goclaw-gitkey-*`) and removes it via a deferred cleanup after exec returns. A `SIGKILL` of the GoClaw process skips that cleanup, leaving the file behind. `os.TempDir()` is per-user on POSIX, so exposure is limited to the GoClaw uid. High-security deployments should run a periodic sweep:
```sh
find "$TMPDIR" -name 'goclaw-gitkey-*' -mmin +60 -delete
```
---
## Webhook Security
> See [Webhooks](/advanced/webhooks) for the full API reference.
### Encryption key required
The webhook subsystem only mounts when `GOCLAW_ENCRYPTION_KEY` is set. Without it, all `/v1/webhooks/*` routes return `404` and the gateway logs:
```
webhook subsystem disabled: GOCLAW_ENCRYPTION_KEY not set
```
This is intentional: an empty key would silently persist raw webhook secrets to the database, defeating the stated DB-leak protection. Set the key and restart to re-enable the subsystem.
### HMAC signing (recommended)
Each webhook row has an `hmac_signing_key` (returned once on create and on rotate). Use HMAC-SHA256 to sign requests instead of the bearer secret:
```
X-Webhook-Id:
X-GoClaw-Signature: t=,v1=
```
Signing algorithm:
```
signing_key = hex.Decode(hmac_signing_key) // hex_64 → 32 raw bytes
payload = "{unix_ts}.{raw_request_body}"
signature = HMAC_SHA256(signing_key, payload)
header = "t={unix_ts},v1={hex(signature)}"
```
**Timestamp skew protection.** Requests where `|now - t| > 300` seconds are rejected. Keep caller clocks synchronized via NTP.
**Replay protection.** After a valid HMAC signature is accepted, the gateway records `sha256(tenant_id|signature_hex)` in a per-process nonce cache (TTL 320 s). Replays return `401` with audit event `security.webhook.hmac_replay`.
**Force HMAC-only auth.** Set `require_hmac: true` on the webhook row to disable bearer-secret authentication entirely. This is the recommended posture for production integrations.
### Additional knobs
| Field | Notes |
|-------|-------|
| `localhost_only` | Restrict callers to `127.0.0.1` / `::1`. Auto-set to `true` on Lite edition. |
| `ip_allowlist` | IPs or CIDR ranges. Empty = any source. `X-Forwarded-For` not trusted. |
| `rate_limit_per_min` | Per-webhook cap (0 = use tenant default). |
See [Webhooks](/advanced/webhooks) for full create payload reference and signature verification examples in Go, Node.js, and Python.
---
## RBAC — 3 Roles
WebSocket RPC methods and HTTP endpoints are gated by role. Roles are hierarchical.
| Role | Key permissions |
|------|----------------|
| **Viewer** | `agents.list`, `config.get`, `sessions.list`, `health`, `status`, `skills.list` |
| **Operator** | + `chat.send`, `chat.abort`, `sessions.delete/reset`, `cron.*`, `skills.update` |
| **Admin** | + `config.apply/patch`, `agents.create/update/delete`, `channels.toggle`, `device.pair.approve/revoke` |
### API Keys
For fine-grained access control, create scoped API keys instead of sharing the gateway token. Keys are hashed with SHA-256 before storage and cached for 5 minutes.
Authentication priority:
1. **Gateway token** → Admin role (full access)
2. **API key** → Role derived from scopes
3. **No token** → Operator (backward compatibility); if no gateway token is configured at all → Admin (dev mode)
Available scopes:
| Scope | Access level |
|-------|-------------|
| `operator.admin` | Full admin access |
| `operator.read` | Read-only (viewer-equivalent) |
| `operator.write` | Read + write operations |
| `operator.approvals` | Exec approval management |
| `operator.pairing` | Device pairing management |
API keys are passed via `Authorization: Bearer {key}` header, same as the gateway token.
---
## Memory File Overwrite Protection
The memory interceptor prevents silent data loss when an agent attempts to overwrite an existing memory file with different content. When a write is issued in replace mode (not append) and the target already contains different content, the previous value is captured and returned to the caller so the agent can be warned before data is lost.
---
## Config Permissions System
GoClaw exposes three RPC methods to control which users can modify an agent's configuration:
| Method | Description |
|--------|-------------|
| `config.permissions.list` | List all granted permissions for an agent |
| `config.permissions.grant` | Grant a specific user permission to modify a config type |
| `config.permissions.revoke` | Revoke a previously granted permission |
By default, config modifications require admin access. Granting permission to a `userId` for a given `scope` and `configType` allows that user to make the specific change without full admin rights.
---
## Goroutine Panic Recovery
GoClaw wraps all background goroutines (tool execution, cron jobs, summarization) in a panic recovery handler via the `safego` package. If a goroutine panics, the error is caught and logged instead of crashing the entire server process. No configuration required — panic recovery is always active.
---
## Hardening Checklist
Use this before exposing GoClaw to the internet or shared users:
- [ ] Set `GOCLAW_GATEWAY_TOKEN` to a strong random token
- [ ] Set `GOCLAW_ENCRYPTION_KEY` to a base64-encoded 32-byte key (`openssl rand -base64 32`) — required for webhooks, workstation credentials, and CLI grant env overrides
- [ ] Store `GOCLAW_ENCRYPTION_KEY` in a secret manager (Vault, AWS Secrets Manager, etc.) — never commit to `config.json` or version control
- [ ] Set `gateway.allowed_origins` to your dashboard domain
- [ ] Set `gateway.rate_limit_rpm` (e.g., `20`) to limit per-user request rate
- [ ] Set `gateway.injection_action` to `"block"` for public-facing deployments
- [ ] Enable exec approval with `tools.execApproval.ask: "on-miss"` (or `"always"`)
- [ ] Enable Docker sandbox with `sandbox.mode: "all"` for untrusted agent workloads
- [ ] Set `POSTGRES_PASSWORD` to a strong password (not the default `"goclaw"`)
- [ ] Enable TLS on PostgreSQL (`sslmode=require` in DSN)
- [ ] Review `gateway.owner_ids` — only trusted user IDs should have owner-level access
- [ ] Set `agents.restrict_to_workspace: true` (this is the default — do not disable)
- [ ] Create scoped API keys for integrations instead of sharing the gateway token
- [ ] Configure `tools.credentialed_exec` for secure CLI tool integrations (gh, aws, etc.)
- [ ] Review shell deny groups — all 15 are on by default; only relax for specific agents that need it
- [ ] Verify sandbox mode does not fall back to host execution (fail-closed)
- [ ] Confirm `GOCLAW_GATEWAY_TOKEN` is set — empty token enables dev mode (admin for all)
- [ ] For webhooks: use `require_hmac: true` on webhook rows — disables bearer auth, forces HMAC-SHA256 signing
- [ ] For webhooks: set `localhost_only: true` (or use `ip_allowlist`) on any webhook not meant to be public
- [ ] No plaintext credentials anywhere in config files — use env vars and secret managers
---
## Security Logging
All security events log at `slog.Warn` with a `security.*` prefix:
| Event | Meaning |
|-------|---------|
| `security.injection_detected` | Prompt injection pattern found |
| `security.injection_blocked` | Message rejected (action = block) |
| `security.rate_limited` | Request rejected by rate limiter |
| `security.cors_rejected` | WebSocket connection rejected by CORS policy |
| `security.message_truncated` | Message truncated at `max_message_chars` |
| `security.credentialed_binary_denied` | Agent attempted exec without a grant |
| `security.credentialed_binary_gate_error` | Grant lookup failed; exec denied fail-closed |
| `security.credentialed_binary_wrapper_too_deep` | Shell wrapper nesting > 3 levels rejected |
Filter all security events:
```bash
./goclaw 2>&1 | grep '"security\.'
# or with structured logs:
journalctl -u goclaw | grep 'security\.'
```
---
## Common Issues
| Problem | Cause | Fix |
|---------|-------|-----|
| Legitimate messages blocked | `injection_action: "block"` too aggressive | Switch to `"warn"` and review logs before re-enabling block |
| Agent can read files outside workspace | `restrict_to_workspace: false` on agent | Re-enable (default is `true`) |
| Credentials appear in tool output | `scrub_credentials: false` | Remove that override — scrubbing is on by default |
| Sandbox not isolating | Sandbox mode is `"off"` | Set `sandbox.mode` to `"non-main"` or `"all"` |
| Encryption key not set | `GOCLAW_ENCRYPTION_KEY` empty | Set before first run; rotating requires re-encrypting stored secrets |
| All users have admin access | `GOCLAW_GATEWAY_TOKEN` not set | Set a strong token; empty = dev mode |
---
## What's Next
- [Exec Approval](../advanced/exec-approval.md) — interactive human-in-the-loop for shell commands
- [Sandbox](../advanced/sandbox.md) — Docker sandbox configuration details
- [Docker Compose](./docker-compose.md) — deploying with security settings via compose overlays
- [Database Setup](./database-setup.md) — PostgreSQL TLS and encrypted secret storage
- [Webhooks](../advanced/webhooks.md) — HMAC-authenticated HTTP endpoints, signature verification, and replay protection
- [Workstations](../advanced/workstations.md) — remote execution targets, permission model, and activity audit
---
# Observability
> Monitor every LLM call, tool use, and agent run — from the built-in dashboard to Jaeger and beyond.
## Overview
GoClaw ships with built-in tracing that records every agent run as a **trace** and each LLM call or tool use as a **span**. Traces are stored in PostgreSQL and visible immediately in the dashboard. If you need to integrate with your existing observability stack (Grafana Tempo, Datadog, Honeycomb, Jaeger), you can export spans over OTLP by building with `-tags otel`.
```mermaid
graph LR
A[Agent Run] --> B[Collector]
B --> C[(PostgreSQL)]
B --> D[OTel Exporter]
D --> E[Jaeger / Tempo / etc.]
C --> F[Dashboard UI]
C --> G[HTTP API]
```
## How Tracing Works
The `tracing.Collector` runs a background flush loop (every 5 seconds) that:
1. Drains a 1000-span in-memory buffer
2. Batch-inserts spans into PostgreSQL
3. Forwards spans to any attached `SpanExporter` (OTel, etc.)
4. Updates per-trace aggregate counters (total tokens, duration, status)
Traces and spans are linked by `trace_id`. Each agent run creates one trace; LLM calls and tool invocations inside that run become child spans.
**Span types recorded:**
| Span type | What it captures |
|-----------|-----------------|
| `llm_call` | Model, tokens in/out, finish reason, latency |
| `tool_call` | Tool name, call ID, duration, status |
| `agent` | Full run lifecycle, output preview |
| `embedding` | Embedding generation for vector store operations |
| `event` | Discrete event marker (no duration) |
## Viewing Traces
### Dashboard
Open the **Traces** section in the web UI (default: `http://localhost:18790`). You can filter by agent, date range, and status.
The Traces UI includes:
- **Timestamps** on each span for precise timing
- **Copy button** on span details for easy export of trace data
- **Syntax highlighting** on JSON payloads in span previews
### Verbose Mode
By default, input messages are truncated to 500 characters in span previews. To store full LLM inputs (useful for debugging):
```bash
export GOCLAW_TRACE_VERBOSE=1
./goclaw
```
In verbose mode, LLM spans store full input/output up to 200 KB; tool spans store full input and output up to 200 KB.
> Use verbose mode only in dev — full messages can be large.
## Trace Export
Individual traces (including all spans and sub-traces) can be exported via HTTP:
```
GET /v1/traces/{traceID}/export
```
The response is **gzip-compressed JSON** containing the trace, its spans, and recursively collected child traces (`sub_traces`). This is useful for offline analysis, bug reports, or archiving long agent runs.
```bash
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:18790/v1/traces/{traceID}/export \
--output trace.json.gz
gunzip trace.json.gz
```
## Trace HTTP API
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v1/traces` | List traces with pagination and filters |
| GET | `/v1/traces/follow` | Poll trace changes for one session or agent |
| GET | `/v1/traces/{id}` | Get trace details with all spans |
| GET | `/v1/traces/{id}/export` | Export trace + sub-traces as gzip JSON |
| GET | `/v1/runs/{runID}/timeline` | Get persisted run-archive timeline items |
### Query Filters (GET /v1/traces)
| Parameter | Type | Description |
|-----------|------|-------------|
| `q` | string | Contains-search across trace ID, trace previews, session/channel labels, agent/channel labels, and span previews/tool names |
| `agent_id` | UUID | Filter by agent |
| `user_id` | string | Filter by user |
| `session_key` | string | Filter by session key |
| `status` | string | `running`, `completed`, `error`, `cancelled` |
| `channel` | string | Filter by raw channel |
| `agent` | string | Contains-search over agent display name and key |
| `channel_query` | string | Contains-search over tenant-scoped channel instance labels |
| `tool_name` | string | Contains-search over span tool names |
| `has_tool_calls` | boolean | Traces with (`true`) or without (`false`) tool calls |
| `from` / `to` | timestamp | `start_time` range filter — `from` inclusive, `to` exclusive |
| `min_input_tokens` / `max_input_tokens` | int | Input token range |
| `min_output_tokens` / `max_output_tokens` | int | Output token range |
| `min_tool_calls` / `max_tool_calls` | int | Tool-call count range |
| `limit` | int | Page size (default 50) |
| `offset` | int | Pagination offset |
> The `goclaw traces` operator CLI wraps these same endpoints. See [CLI Commands](../reference/cli-commands.md) for the command reference.
### Pointing operator commands at a gateway
HTTP operator commands (and the dashboard's API) resolve their base URL in this order:
1. `--server ` flag (highest priority)
2. `GOCLAW_SERVER` environment variable
3. `GOCLAW_GATEWAY_URL` environment variable (fallback)
4. The gateway host/port from `config.json` (defaults to `http://127.0.0.1:18790`)
`GOCLAW_GATEWAY_URL` now drives **all** HTTP operator commands, not just `auth`. Pair it with `--token` / `GOCLAW_GATEWAY_TOKEN` for the bearer token.
## OpenTelemetry Export
The OTel exporter is compiled in only when you add `-tags otel`. The default build has zero OTel dependencies, saving approximately 15–20 MB from the binary.
### Build with OTel support
```bash
go build -tags otel -o goclaw .
```
### Configure via environment
```bash
export GOCLAW_TELEMETRY_ENABLED=true
export GOCLAW_TELEMETRY_ENDPOINT=localhost:4317 # OTLP gRPC endpoint
export GOCLAW_TELEMETRY_PROTOCOL=grpc # "grpc" (default) or "http"
export GOCLAW_TELEMETRY_INSECURE=true # skip TLS for local dev
export GOCLAW_TELEMETRY_SERVICE_NAME=goclaw-gateway
```
Or via `config.json`:
```json
{
"telemetry": {
"enabled": true,
"endpoint": "tempo:4317",
"protocol": "grpc",
"insecure": false,
"service_name": "goclaw-gateway"
}
}
```
Spans are exported using `gen_ai.*` semantic conventions (OpenTelemetry GenAI SIG), plus `goclaw.*` custom attributes for correlation with the PostgreSQL trace store.
The OTel exporter batches spans with a max batch size of 100 and a 5-second timeout.
## Jaeger Integration
The included `docker-compose.otel.yml` overlay spins up Jaeger all-in-one and wires it to GoClaw automatically:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.otel.yml \
up
```
Jaeger UI is available at **http://localhost:16686**.
The overlay sets:
```yaml
# docker-compose.otel.yml (excerpt)
services:
jaeger:
image: jaegertracing/all-in-one:1.68.0
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
goclaw:
build:
args:
ENABLE_OTEL: "true" # compiles with -tags otel
environment:
- GOCLAW_TELEMETRY_ENABLED=true
- GOCLAW_TELEMETRY_ENDPOINT=jaeger:4317
- GOCLAW_TELEMETRY_PROTOCOL=grpc
- GOCLAW_TELEMETRY_INSECURE=true
```
## Key Attributes in Exported Spans
| Attribute | Description |
|-----------|-------------|
| `gen_ai.request.model` | LLM model name |
| `gen_ai.system` | Provider (anthropic, openai, etc.) |
| `gen_ai.usage.input_tokens` | Tokens consumed as input |
| `gen_ai.usage.output_tokens` | Tokens produced as output |
| `gen_ai.response.finish_reason` | Why the model stopped |
| `goclaw.span_type` | `llm_call`, `tool_call`, `agent`, `embedding`, `event` |
| `goclaw.tool.name` | Tool name for tool spans |
| `goclaw.trace_id` | UUID linking back to PostgreSQL |
| `goclaw.duration_ms` | Wall-clock duration |
## Usage Analytics
GoClaw aggregates token counts and costs into hourly snapshots via a background worker (runs at HH:05:00 UTC). These power the dashboard's usage charts and the `/v1/usage` API endpoint.
The `usage_snapshots` table stores pre-computed aggregates per agent, user, and provider — so dashboard queries stay fast even with millions of spans. On startup, the worker backfills any missed hours automatically.
An `activity_logs` table records admin actions, config changes, and security events as an audit trail.
## Real-Time Log Streaming
Connected WebSocket clients can subscribe to live log events. The `LogTee` layer intercepts all `slog` records and:
1. Caches the last 100 entries in a ring buffer (new subscribers get recent history)
2. Broadcasts to subscribed clients at their chosen log level
3. Auto-redacts sensitive fields: `key`, `token`, `secret`, `password`, `dsn`, `credential`, `authorization`, `cookie`
This means dashboard users see real-time logs without SSH access, and secrets never leak through the log stream.
## Common Issues
| Issue | Likely cause | Fix |
|-------|-------------|-----|
| No spans in Jaeger | Binary built without `-tags otel` | Rebuild with `go build -tags otel` |
| `GOCLAW_TELEMETRY_ENABLED` ignored | OTel build tag missing | Check `ENABLE_OTEL: "true"` in docker build args |
| Span buffer full (log warning) | High agent throughput | Increase buffer or reduce flush interval in code |
| Input previews truncated | Normal behavior | Set `GOCLAW_TRACE_VERBOSE=1` for full inputs |
| Spans appear in DB but not Jaeger | Endpoint misconfigured | Check `GOCLAW_TELEMETRY_ENDPOINT` and port reachability |
## What's Next
- [Production Checklist](/deploy-checklist) — monitoring and alerting recommendations
- [Docker Compose Setup](/deploy-docker-compose) — full compose file reference
- [Security Hardening](/deploy-security) — securing your deployment
---
# Tailscale Integration
> Expose your GoClaw gateway securely on your Tailscale network — no port forwarding, no public IP required.
## Overview
GoClaw can join your [Tailscale](https://tailscale.com) network as a named node, making the gateway reachable from any of your devices without opening firewall ports. This is ideal for self-hosted setups where you want private remote access from your laptop, phone, or CI runners.
The Tailscale listener runs **alongside** the regular HTTP listener on the same handler — you get both local and Tailscale access simultaneously.
This feature is opt-in and compiled in only when you build with `-tags tsnet`. The default binary has zero Tailscale dependencies.
## How It Works
```mermaid
graph LR
A[Your laptop] -->|Tailscale network| B[goclaw-gateway node]
C[Your phone] -->|Tailscale network| B
B --> D[Gateway handler]
E[Local network] -->|Port 18790| D
```
When `GOCLAW_TSNET_HOSTNAME` is set, GoClaw starts a `tsnet.Server` that connects to Tailscale and listens on port 80 (or 443 with TLS). The Tailscale node appears in your Tailscale admin console as a regular device.
## Build with Tailscale Support
```bash
go build -tags tsnet -o goclaw .
```
Or with Docker Compose using the provided overlay:
```bash
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.tailscale.yml \
up
```
The overlay passes `ENABLE_TSNET: "true"` as a build arg, which compiles the binary with `-tags tsnet`.
## Configuration
### Required
```bash
# From https://login.tailscale.com/admin/settings/keys
# Use a reusable auth key for long-lived deployments
export GOCLAW_TSNET_AUTH_KEY=tskey-auth-xxxxxxxxxxxxxxxx
```
### Optional
```bash
# Tailscale device name (default: goclaw-gateway)
export GOCLAW_TSNET_HOSTNAME=my-goclaw
# Directory for Tailscale state (persisted across restarts)
# Default: OS user config dir
export GOCLAW_TSNET_DIR=/app/tsnet-state
```
Or via `config.json` (auth key is **never** stored in config — env only):
```json
{
"tailscale": {
"hostname": "my-goclaw",
"state_dir": "/app/tsnet-state",
"ephemeral": false,
"enable_tls": false
}
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `hostname` | `goclaw-gateway` | Tailscale device name |
| `state_dir` | OS user config dir | Persists Tailscale identity across restarts |
| `ephemeral` | `false` | If true, node is automatically removed from your tailnet when GoClaw stops — useful for CI/CD or short-lived containers |
| `enable_tls` | `false` | Use Tailscale-managed HTTPS certs via Let's Encrypt (listens on `:443` instead of `:80`) |
## Docker Compose Setup
The `docker-compose.tailscale.yml` overlay mounts a named volume for Tailscale state so the node identity survives container restarts:
```yaml
# docker-compose.tailscale.yml (full file)
services:
goclaw:
build:
args:
ENABLE_TSNET: "true"
environment:
- GOCLAW_TSNET_HOSTNAME=${GOCLAW_TSNET_HOSTNAME:-goclaw-gateway}
- GOCLAW_TSNET_AUTH_KEY=${GOCLAW_TSNET_AUTH_KEY}
volumes:
- tsnet-state:/app/tsnet-state
volumes:
tsnet-state:
```
Set your auth key in `.env`:
```bash
GOCLAW_TSNET_AUTH_KEY=tskey-auth-xxxxxxxxxxxxxxxx
GOCLAW_TSNET_HOSTNAME=my-goclaw
```
Then bring it up:
```bash
docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.tailscale.yml up -d
```
## Accessing the Gateway
Once running, your gateway is reachable at:
```
http://my-goclaw.your-tailnet.ts.net # HTTP (default)
https://my-goclaw.your-tailnet.ts.net # HTTPS (if enable_tls: true)
```
You can find the full hostname in your [Tailscale admin console](https://login.tailscale.com/admin/machines).
## Common Issues
| Issue | Likely cause | Fix |
|-------|-------------|-----|
| Node not appearing in Tailscale console | Invalid or expired auth key | Generate a new reusable key at admin/settings/keys |
| Tailscale listener not starting | Binary built without `-tags tsnet` | Rebuild with `go build -tags tsnet` |
| `GOCLAW_TSNET_HOSTNAME` ignored | Tag missing from build | Check `ENABLE_TSNET: "true"` in docker build args |
| State lost on container restart | Missing volume mount | Ensure `tsnet-state` volume is mounted to `state_dir` |
| Connection refused from Tailscale | `enable_tls` mismatch | Check whether you're using HTTP or HTTPS |
## What's Next
- [Production Checklist](/deploy-checklist) — secure your deployment end to end
- [Security Hardening](/deploy-security) — CORS, rate limits, and token auth
- [Docker Compose Setup](/deploy-docker-compose) — full compose overlay reference
---
# Production Checklist
> Everything you need to verify before taking GoClaw from development to production.
## Overview
This checklist covers the critical steps to harden, secure, and reliably operate a GoClaw gateway in production. Work through each section top to bottom before going live.
## 2. Secrets and Encryption
- [ ] `GOCLAW_ENCRYPTION_KEY` is set to a random 32-byte hex string — **back this up**. Losing it means losing all encrypted API keys stored in the database.
- [ ] `GOCLAW_GATEWAY_TOKEN` is set to a strong random value — required for WebSocket and HTTP auth
- [ ] Neither secret appears in `config.json`, git history, or logs
- [ ] All provider API keys are set via environment (`GOCLAW_ANTHROPIC_API_KEY`, etc.) or added through the dashboard (where they are stored encrypted with AES-256-GCM)
```bash
# Generate secrets if you haven't run onboard/prepare-env.sh
export GOCLAW_ENCRYPTION_KEY=$(openssl rand -hex 32)
export GOCLAW_GATEWAY_TOKEN=$(openssl rand -hex 32)
```
> Back up `GOCLAW_ENCRYPTION_KEY` in a secrets manager (e.g. AWS Secrets Manager, 1Password, Vault). If you rotate it, all encrypted API keys in the database become unreadable.
---
## 3. Network and TLS
- [ ] TLS termination is in place (nginx, Caddy, Cloudflare, or load balancer) — GoClaw itself does not terminate TLS in standard mode
- [ ] Gateway is **not** exposed directly on a public port without TLS
- [ ] `gateway.allowed_origins` is set to your actual client origins (empty = allow all WebSocket origins)
```json
{
"gateway": {
"allowed_origins": ["https://your-dashboard.example.com"]
}
}
```
---
## 4. Rate Limiting
- [ ] `gateway.rate_limit_rpm` is set (default: 20 requests/minute per user, 0 = disabled)
- [ ] `tools.rate_limit_per_hour` is set (default: 150 tool executions/hour per session, 0 = disabled)
- [ ] Webhook rate limiting is built-in (30 requests/60s per source, max 4096 tracked sources) — no configuration needed
```json
{
"gateway": {
"rate_limit_rpm": 20
},
"tools": {
"rate_limit_per_hour": 150
}
}
```
---
## 5. Sandbox Configuration
If agents execute code, review the sandbox settings:
- [ ] `sandbox.mode` is set: `"off"` (no sandbox), `"non-main"` (sandbox subagents only), or `"all"` (sandbox everything)
- [ ] `sandbox.memory_mb` and `sandbox.cpus` are tuned for your workload (defaults: 512 MB, 1 CPU)
- [ ] `sandbox.network_enabled` is `false` unless agents explicitly need network access
- [ ] `sandbox.read_only_root` is `true` (default) for immutable container root filesystem
- [ ] `sandbox.timeout_sec` is set to a reasonable limit (default: 300s)
- [ ] `sandbox.idle_hours` tuned (default: 24 — removes containers idle longer than this)
- [ ] `sandbox.max_age_days` set (default: 7 — removes containers older than this)
```json
{
"agents": {
"defaults": {
"sandbox": {
"mode": "non-main",
"memory_mb": 512,
"cpus": 1.0,
"network_enabled": false,
"read_only_root": true,
"timeout_sec": 120
}
}
}
}
```
---
## 6. Security Settings
- [ ] `gateway.injection_action` is set to `"warn"` (default) or `"block"` — never `"off"` in production
- [ ] `tools.exec_approval.security` is `"full"` (default) — blocks dangerous shell patterns
- [ ] `agents.defaults.restrict_to_workspace` is `true` (default) — prevents path traversal outside workspace
- [ ] Review `tools.web_fetch` domain allow/deny lists if agents browse the web
---
## 7. Monitoring and Alerting
- [ ] Log output is collected (stdout/stderr) — GoClaw uses structured JSON logging via `slog`
- [ ] Alert on repeated `slog.Warn("security.*")` log entries — these indicate blocked attacks or anomalies
- [ ] Alert on `tracing: span buffer full` — indicates the collector is falling behind under load
- [ ] Uptime monitoring is configured (e.g. ping `/health` or the gateway port)
- [ ] Consider enabling OTel export for trace-level visibility — see [Observability](/deploy-observability)
- [ ] Interactive API documentation is available at `/docs` (Swagger UI) and `/v1/openapi.json` for integration testing
---
## 8. Operational Hygiene
- [ ] Log rotation is configured if writing to files (use `logrotate` or your container runtime's log driver)
- [ ] `GOCLAW_AUTO_UPGRADE=true` is set **only** if you accept automatic schema migrations on startup; otherwise upgrade explicitly with `./goclaw upgrade`
- [ ] A runbook exists for: restart, rollback, DB restore, and encryption key rotation
- [ ] Upgrade procedure is documented and tested — see [Upgrading](/deploy-upgrading)
---
## 9. API Key Management
- [ ] Consider creating scoped API keys instead of sharing the gateway token
- [ ] API keys support fine-grained scopes: `operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`
- [ ] Keys are hashed (SHA-256) before storage — the plaintext is shown only at creation time
- [ ] Set up key rotation policy — keys can be revoked individually without affecting others
```json
// Example: create a read-only key for monitoring
// via dashboard or API
{
"name": "monitoring-readonly",
"scopes": ["operator.read"]
}
```
---
## 10. Concurrency Tuning
GoClaw uses lane-based scheduling to limit concurrent agent runs by type:
| Environment Variable | Default | Purpose |
|---------------------|---------|---------|
| `GOCLAW_LANE_MAIN` | `30` | Max concurrent main agent runs |
| `GOCLAW_LANE_SUBAGENT` | `50` | Max concurrent subagent runs |
| `GOCLAW_LANE_DELEGATE` | `100` | Max concurrent delegated runs |
| `GOCLAW_LANE_CRON` | `30` | Max concurrent cron job runs |
Tune these based on your server resources and expected load. Lower values reduce memory pressure; higher values improve throughput.
---
## 11. Gateway Tuning
Review these gateway settings for your deployment:
| Setting | Default | Description |
|---------|---------|-------------|
| `gateway.owner_ids` | `[]` | User IDs with owner-level access — keep this list minimal |
| `gateway.max_message_chars` | `32000` | Max user message size before truncation |
| `gateway.inbound_debounce_ms` | `1000` | Merge rapid consecutive messages (ms) |
| `gateway.task_recovery_interval_sec` | `300` | How often team tasks are checked for recovery |
- [ ] `gateway.owner_ids` contains only trusted admin user IDs
- [ ] `gateway.max_message_chars` is appropriate for your use case (lower = less token spend)
---
## Quick Verification
### First-Time Setup
For new installations, the `onboard` command handles initial setup interactively:
```bash
./goclaw onboard
```
It generates encryption and gateway tokens, runs database migrations, and walks you through basic configuration. You can also run `prepare-env.sh` for non-interactive secret generation.
### System Health Check
The `doctor` command runs a comprehensive check of your environment:
```bash
./goclaw doctor
```
It validates: runtime info, config file, database connection and schema version, provider API keys, channel credentials, external tools (docker, curl, git), and workspace directories.
```bash
# Check schema and pending migrations
./goclaw upgrade --status
# Verify gateway starts and connects to DB
./goclaw &
curl http://localhost:18790/health
# Confirm secrets are not exposed in logs
# Look for "***" masking, not raw key values
```
## Common Issues
| Issue | Likely cause | Fix |
|-------|-------------|-----|
| Gateway refuses to start | Schema outdated | Run `./goclaw upgrade` |
| Encrypted API keys unreadable | Wrong `GOCLAW_ENCRYPTION_KEY` | Restore correct key from backup |
| WebSocket connections rejected | `allowed_origins` too restrictive | Add your dashboard origin to the list |
| Rate limit too aggressive | Default 20 RPM for high-traffic use | Increase `gateway.rate_limit_rpm` |
| Agents escape workspace | `restrict_to_workspace` disabled | Set to `true` in config |
## What's Next
- [Upgrading](/deploy-upgrading) — how to upgrade GoClaw safely
- [Observability](/deploy-observability) — set up tracing and alerting
- [Security Hardening](/deploy-security) — deeper security configuration
- [Docker Compose Setup](/deploy-docker-compose) — production compose patterns
---
# Upgrading
> How to safely upgrade GoClaw — binary, database schema, and data migrations — with zero surprises.
## Overview
A GoClaw upgrade has two parts:
1. **SQL migrations** — schema changes applied by `golang-migrate` (idempotent, versioned)
2. **Data hooks** — optional Go-based data transformations that run after schema migrations (e.g. backfilling a new column)
The `./goclaw upgrade` command handles both in the correct order. It is safe to run multiple times — it is fully idempotent. The current required schema version is **80**.
```mermaid
graph LR
A[Backup DB] --> B[Replace binary]
B --> C[goclaw upgrade --dry-run]
C --> D[goclaw upgrade]
D --> E[Start gateway]
E --> F[Verify]
```
## The Upgrade Command
```bash
# Preview what would happen (no changes applied)
./goclaw upgrade --dry-run
# Show current schema version and pending items
./goclaw upgrade --status
# Apply all pending SQL migrations and data hooks
./goclaw upgrade
```
### Status output explained
```
App version: v1.2.0 (protocol 3)
Schema current: 12
Schema required: 14
Status: UPGRADE NEEDED (12 -> 14)
Pending data hooks: 1
- 013_backfill_agent_slugs
Run 'goclaw upgrade' to apply all pending changes.
```
| Status | Meaning |
|--------|---------|
| `UP TO DATE` | Schema matches binary — nothing to do |
| `UPGRADE NEEDED` | Run `./goclaw upgrade` |
| `BINARY TOO OLD` | Your binary is older than the DB schema — upgrade the binary |
| `DIRTY` | A migration failed partway — see recovery below |
## Standard Upgrade Procedure
### Step 1 — Back up the database
```bash
pg_dump -Fc "$GOCLAW_POSTGRES_DSN" > goclaw-backup-$(date +%Y%m%d).dump
```
Never skip this. Schema migrations are not automatically reversible.
### Step 2 — Replace the binary
```bash
# Download new binary or build from source
go build -o goclaw-new .
# Verify version
./goclaw-new upgrade --status
```
### Step 3 — Dry run
```bash
./goclaw-new upgrade --dry-run
```
Review what SQL migrations and data hooks will be applied.
### Step 4 — Apply
```bash
./goclaw-new upgrade
```
Expected output:
```
App version: v1.2.0 (protocol 3)
Schema current: 12
Schema required: 14
Applying SQL migrations... OK (v12 -> v14)
Running data hooks... 1 applied
Upgrade complete.
```
### Step 5 — Start the gateway
```bash
mv goclaw-new goclaw
./goclaw
```
### Step 6 — Verify
- Open the dashboard and confirm agents load correctly
- Check logs for any `ERROR` or `WARN` lines during startup
- Run a test agent message end-to-end
## Docker Compose Upgrade
Use the `docker-compose.upgrade.yml` overlay to run the upgrade as a one-shot container:
```bash
# Dry run
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade --dry-run
# Apply
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade
# Check status
docker compose \
-f docker-compose.yml \
-f docker-compose.postgres.yml \
-f docker-compose.upgrade.yml \
run --rm upgrade --status
```
The `upgrade` service starts, runs `goclaw upgrade`, then exits. The `--rm` flag removes the container automatically.
> Make sure `GOCLAW_ENCRYPTION_KEY` is set in your `.env` — the upgrade service needs it to access encrypted config.
## Auto-Upgrade on Startup
For CI or ephemeral environments where manual upgrade steps are impractical:
```bash
export GOCLAW_AUTO_UPGRADE=true
./goclaw
```
When set, the gateway checks the schema on startup and applies any pending SQL migrations and data hooks automatically before serving traffic.
**Use with caution in production** — prefer explicit `./goclaw upgrade` so you control timing and have a backup first.
## Rollback Procedure
GoClaw does not provide automatic rollback. If something goes wrong:
### Option A — Restore from backup (safest)
```bash
# Stop gateway
# Restore DB from pre-upgrade backup
pg_restore -d "$GOCLAW_POSTGRES_DSN" goclaw-backup-20250308.dump
# Restore previous binary
./goclaw-old
```
### Option B — Fix a dirty schema
If a migration failed partway, the schema is marked dirty:
```
Status: DIRTY (failed migration)
Fix: ./goclaw migrate force 13
Then: ./goclaw upgrade
```
Force the migration version back to the last known good state, then re-run upgrade:
```bash
./goclaw migrate force 13
./goclaw upgrade
```
Only do this if you understand what the failed migration was doing. When in doubt, restore from backup.
### All migrate subcommands
```bash
./goclaw migrate up # Apply pending migrations
./goclaw migrate down # Roll back one step
./goclaw migrate down 3 # Roll back 3 steps
./goclaw migrate version # Show current version + dirty state
./goclaw migrate force # Force version (recovery only)
./goclaw migrate goto # Migrate to a specific version
./goclaw migrate drop # DROP ALL TABLES (dangerous — use only in dev)
```
> **Data hooks tracking:** GoClaw tracks post-migration Go transforms in a separate `data_migrations` table (distinct from `schema_migrations`). Run `./goclaw upgrade --status` to see both SQL migration version and pending data hooks.
## Recent Migrations
### v3.12.x — Highlights and Breaking Changes
#### Schema 58 → 73
Migrations `000058`–`000073` are applied automatically on next startup (`./goclaw upgrade` or `GOCLAW_AUTO_UPGRADE=true`) — no manual steps required. Highlights:
- `000068_bitrix_portals` — adds `bitrix_portals` for per-tenant Bitrix24 portal OAuth state (credentials + tokens stored AES-256-GCM).
- `000069_browser_cookies` — adds `browser_cookies` for user-selected, encrypted server-side browser cookies, scoped by tenant/user/agent.
- `000070_usage_caps_pricing` — adds `usage_pricing_catalog` (synced per-model pricing) and `usage_pricing_overrides` (per-tenant price overrides).
- `000071_usage_cap_policies` — adds the usage-cap enforcement tables: `usage_cap_policies`, `usage_cap_counters`, `usage_cap_reservations`, `usage_cap_events`.
- `000072_agent_budget_usage_cap_bridge` — adds a `source` column to `usage_cap_policies` and **backfills** one `month`-window policy per agent that has a non-NULL `budget_monthly_cents` (1 cent = 10,000 micros, `source = 'agent_budget_monthly_cents'`). Existing per-agent monthly budgets are now enforced through the usage-cap engine. Idempotent (`ON CONFLICT DO NOTHING`); no action required.
- `000073_secure_cli_credential_type` — adds nullable `credential_type` + `host_scope` to `secure_cli_user_credentials` and `adapter_name` to `secure_cli_binaries` for the typed credential-adapter framework. All columns NULL-by-default to preserve legacy passthrough behavior — no action required.
#### Schema 73 → 80
Migrations `000074`–`000080` are applied automatically on next startup (`./goclaw upgrade` or `GOCLAW_AUTO_UPGRADE=true`) — no manual steps required. All new tables; existing data is untouched. Highlights:
- `000074_run_timeline_items` — adds `run_timeline_items` for an archived, ordered per-run timeline (messages/tool calls) used to replay runs; links to traces and spans.
- `000075_channel_context_capabilities` — adds `mcp_context_grants`, `mcp_context_credentials`, `secure_cli_context_grants`, and `secure_cli_context_credentials` for per-channel-instance scoped MCP and secure-CLI grants with encrypted credentials.
- `000076_channel_memory_extraction` — adds `channel_memory_extraction_runs` and `channel_memory_extraction_items` for passive, scheduled extraction of durable memory from channel history (summaries only, no raw bodies) with a review queue.
- `000077_secure_cli_agent_credentials` — adds `secure_cli_agent_credentials` for per-agent typed CLI credentials, separate from the grants policy; uses composite FKs `(binary_id, tenant_id)` and `(agent_id, tenant_id)`.
- `000078_skill_user_grants_tenant_unique` — replaces the `skill_user_grants` unique key with tenant-scoped `(skill_id, user_id, tenant_id)`. No action required.
- `000079_skill_self_evolution` — adds `skill_evolution_settings`, `skill_usage_metrics`, `skill_improvement_suggestions`, and `skill_versions` for per-skill usage tracking and suggested/applied improvements. **Backfills** one `skill_versions` row per existing non-deleted skill. Idempotent (`ON CONFLICT DO NOTHING`).
- `000080_usage_event_analytics` — adds `usage_events` (raw per-event analytics) and `usage_event_rollups` (pre-aggregated hourly rollups) backing the usage analytics dashboard.
**Gateway-triggered upgrades (host release-upgrade flow):** When a dashboard/API-triggered upgrade is run on a systemd host, `goclaw-upgrade-release` now re-launches itself as a transient `systemd-run` unit so stopping `goclaw` during deploy no longer kills the upgrade job. Stale `running` status records are also superseded after 30 minutes, and the deploy wait loop tolerates transient `502`s during restart. No configuration change required.
#### v3.11.3
- fix(migrations): `000057_heartbeat_provider_fk_set_null` — defensive orphan cleanup; drops existing FK by constraint-name lookup (handles auto-generated name drift), re-adds with `ON DELETE SET NULL`. Brief `ACCESS EXCLUSIVE` lock on `agent_heartbeats` during `ALTER TABLE` (sub-second on small tables; heartbeat workers may pause briefly).
- SQLite: schema v25 → v26 — full table rebuild for `agent_heartbeats` with updated FK clause; explicit 25-column `INSERT … SELECT` preserves all existing rows. `idx_heartbeats_due` recreated.
**Docker users:** MUST pull `ghcr.io/nextlevelbuilder/goclaw:v3.11.3` AND run `goclaw upgrade`. A stale v3.11.2 image fails on boot with:
```
schema version mismatch: required 57, current 56
```
**Bare-metal users:** rebuild binary and run `./goclaw upgrade`.
#### v3.11.2
- fix(migrations): drop scope-consistency check before backfill UPDATEs — migration #56 follow-up; prevents constraint errors when backfilling over legacy data
**Migration step:** Migration #56 is applied automatically on next startup (`goclaw upgrade` or `GOCLAW_AUTO_UPGRADE=true`). No manual steps required.
#### v3.11.1
- ci(release): native arm64 runners + split-build manifest pattern
> **Asset naming note:** The OTel variant asset has been dropped from the release pipeline. If your deploy script downloads an asset matching `*-otel*`, switch to the regular asset.
#### v3.11.0
**New features:**
- feat: Native `image_generation` for Codex + OpenAI-compat — tri-level gate (provider capability → agent flag → per-request header `x-goclaw-no-image-gen`)
- feat: `send_file` builtin tool + `DeliveredMedia` cross-tool dedup
- feat: `tools.shellDenyGroups` — runtime-reloadable global config for deny-groups (no restart required)
- feat: Vault `chat_id` isolation — migration #56 adds `chat_id` column to `vault_documents` to scope documents per chat
- feat: Pancake — TikTok + Shopee sub-platform support; private-reply stateless DM refactor
- feat: Codex pool — collapse `primary_first` on public surface, per-modality round-robin (chat vs image)
- feat: Dynamic compact `max_tokens = clamp(in/25, 1024, 8192)` replaces static 4096; tool-schema tokens counted in `OverheadTokens`
- feat: TTS — tenant `tts.timeout_ms`; Gemini text-only 400 fix; default model bump `gemini-3.1-flash-tts-preview`
- feat: Telegram bot self-identity injection + own @mention strip
- fix: Discord allowlist gate (#985/#1010)
- chore: Release pipeline — native arm64 runners, OTel variant DROPPED (asset renamed)
**BREAKING (clients):** Codex pool API responses now return `priority_order` in place of legacy `primary_first` / `manual` for the same routing config. Request bodies still accept legacy values for backward compatibility. Update consumers comparing strategy strings literally.
---
# Personal Assistant
> Single-user AI assistant on Telegram with memory and a custom personality.
## Overview
This recipe walks you from zero to a personal assistant: one gateway, one agent, one Telegram bot. By the end your assistant will remember things across conversations and respond with the personality you give it.
**What you need:**
- GoClaw binary (see [Getting Started](../getting-started/))
- PostgreSQL database with pgvector
- A Telegram bot token from @BotFather
- An API key from any supported LLM provider
## Step 1: Run the setup wizard
```bash
./goclaw onboard
```
The interactive wizard covers everything in one pass:
1. **Provider** — choose your LLM provider (OpenRouter is recommended for access to many models)
2. **Gateway port** — default `18790`
3. **Channel** — select `Telegram`, paste your bot token
4. **Features** — select `Memory` (vector search) and `Browser` (web access)
5. **Database** — paste your Postgres DSN
The wizard saves a `config.json` (no secrets) and a `.env.local` file (secrets only). Start the gateway:
```bash
source .env.local && ./goclaw
```
## Step 2: Understand the default config
After onboarding, `config.json` looks roughly like this:
```json
{
"agents": {
"defaults": {
"workspace": "~/.goclaw/workspace",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"max_tokens": 8192,
"max_tool_iterations": 20,
"memory": {
"enabled": true,
"embedding_provider": ""
}
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "",
"dm_policy": "pairing",
"reaction_level": "minimal"
}
},
"gateway": {
"host": "0.0.0.0",
"port": 18790
},
"tools": {
"browser": {
"enabled": true,
"headless": true
}
}
}
```
`dm_policy: "pairing"` means new users must pair via a browser code before the bot responds. This protects your bot from strangers.
## Step 3: Pair your Telegram account
Open the web dashboard at `http://localhost:18790`. Go to the pairing page and follow the instructions — you'll send a code to your Telegram bot, and the dashboard confirms the link. Once paired, the bot responds to your messages.
Alternatively, use `./goclaw agent chat` to chat directly in the terminal without pairing.
## Step 4: Customize the personality (SOUL.md)
On first chat, the agent seeds a `SOUL.md` file in your user context. Edit it in the dashboard:
Go to **Agents → your agent → Files tab → SOUL.md** and edit inline. For example:
```markdown
You are a sharp, direct research partner. You prefer short answers over long explanations
unless the user explicitly asks to dig deeper. You have a dry sense of humor.
You never hedge with "I think" or "I believe" — just state your answer.
```
Click **Save** when done.
Via API
```bash
curl -X PUT http://localhost:18790/v1/agents/default/files/SOUL.md \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: your-user-id" \
-H "Content-Type: text/plain" \
--data-binary @- <<'EOF'
You are a sharp, direct research partner. You prefer short answers over long explanations
unless the user explicitly asks to dig deeper. You have a dry sense of humor.
You never hedge with "I think" or "I believe" — just state your answer.
EOF
```
See [Editing Personality](/editing-personality) for full SOUL.md reference.
## Step 5: Enable memory
Memory is already on if you selected it in the wizard. The agent uses SQLite + pgvector for hybrid search. Notes are stored with `memory_save` and searched with `memory_search` automatically.
To verify memory is active, send your bot: "Remember that I prefer Python over JavaScript." Then in a later session: "What programming language do I prefer?" — the agent recalls from memory.
You can also check memory status in the dashboard: go to **Agents → your agent** and verify the memory config shows as enabled.
## Optional: Personalize your agent
A few extra touches you can configure in the dashboard under **Agents → your agent**:
- **Emoji:** Set an emoji icon via the emoji selector in the agent detail page — this shows in the agent list and chat UI
- **Skill learning:** (Predefined agents only) Toggle **Skill Learning** to let the agent capture reusable workflows as skills after complex tasks. Set the nudge interval to control how often the agent suggests creating skills.
## Common Issues
| Problem | Solution |
|---------|----------|
| Bot doesn't respond in Telegram | Check `dm_policy`. With `"pairing"`, you must complete browser pairing first. Set `"open"` to skip pairing. |
| Memory not working | Confirm `memory.enabled: true` in config and that an embedding provider has an API key. Check gateway logs for embedding errors. |
| "No provider configured" error | Ensure the API key env var is set. Run `source .env.local` before `./goclaw`. |
| Bot responds to everyone | Set `dm_policy: "allowlist"` and `allow_from: ["your_username"]` in `channels.telegram`. |
## What's Next
- [Editing Personality](/editing-personality) — customize SOUL.md, IDENTITY.md, USER.md
- [Telegram Channel](/channel-telegram) — full Telegram configuration reference
- [Team Chatbot](/recipe-team-chatbot) — add specialist agents for different tasks
- [Multi-Channel Setup](/recipe-multi-channel) — put the same agent on Discord and WebSocket too
---
# Team Chatbot
> Multi-agent team with a lead coordinator and specialist sub-agents for different tasks.
## Overview
This recipe builds a team of three agents: a lead that handles conversation and delegates, plus two specialists (a researcher and a coder). Users talk only to the lead — it decides when to call in a specialist. Teams use GoClaw's built-in delegation system, so the lead can run specialists in parallel and synthesize results.
**What you need:**
- A working gateway (run `./goclaw onboard` first)
- Web dashboard access at `http://localhost:18790`
- At least one LLM provider configured
## Step 1: Create the specialist agents
Specialists must be **predefined** agents — only predefined agents can receive delegations.
Open the web dashboard and go to **Agents → Create Agent**. Create two specialists:
**Researcher agent:**
- **Key:** `researcher`
- **Display name:** Research Specialist
- **Type:** Predefined
- **Provider / Model:** Choose your preferred provider and model
- **Description:** "Deep research specialist. Searches the web, reads pages, synthesizes findings into concise reports with sources. Factual, thorough, cites everything."
Click **Save**. The `description` field triggers **summoning** — the gateway uses the LLM to auto-generate SOUL.md and IDENTITY.md. The agent status shows `summoning` then transitions to `active`.
**Coder agent:**
Repeat the same flow with:
- **Key:** `coder`
- **Display name:** Code Specialist
- **Type:** Predefined
- **Description:** "Senior software engineer. Writes clean, production-ready code. Explains implementation decisions. Prefers simple solutions. Tests edge cases."
Wait for both agents to reach `active` status before proceeding.
Via API
```bash
# Researcher
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "researcher",
"display_name": "Research Specialist",
"agent_type": "predefined",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"other_config": {
"description": "Deep research specialist. Searches the web, reads pages, synthesizes findings into concise reports with sources. Factual, thorough, cites everything."
}
}'
# Coder
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "coder",
"display_name": "Code Specialist",
"agent_type": "predefined",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"other_config": {
"description": "Senior software engineer. Writes clean, production-ready code. Explains implementation decisions. Prefers simple solutions. Tests edge cases."
}
}'
```
Poll agent status until `summoning` → `active`:
```bash
curl http://localhost:18790/v1/agents/researcher \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Step 2: Create the lead agent
The lead is an **open** agent — each user gets their own context, making it feel like a personal assistant that happens to have a team behind it.
In the dashboard, go to **Agents → Create Agent**:
- **Key:** `lead`
- **Display name:** Assistant
- **Type:** Open
- **Provider / Model:** Choose your preferred provider and model
Click **Save**.
Via API
```bash
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "lead",
"display_name": "Assistant",
"agent_type": "open",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929"
}'
```
## Step 3: Create the team
Go to **Teams → Create Team** in the dashboard:
- **Name:** Assistant Team
- **Description:** Personal assistant team with research and coding capabilities
- **Lead:** Select `lead`
- **Members:** Add `researcher` and `coder`
Click **Save**. Creating a team automatically sets up delegation links from the lead to each member. The lead agent's context now includes a `TEAM.md` file listing available specialists and how to delegate to them.
Via API
Team management uses WebSocket RPC. Connect to `ws://localhost:18790/ws` and send:
```json
{
"type": "req",
"id": "1",
"method": "teams.create",
"params": {
"name": "Assistant Team",
"lead": "lead",
"members": ["researcher", "coder"],
"description": "Personal assistant team with research and coding capabilities"
}
}
```
## Step 4: Connect a channel
Go to **Channels → Create Instance** in the dashboard:
- **Channel type:** Telegram (or Discord, Slack, etc.)
- **Name:** `team-telegram`
- **Agent:** Select `lead`
- **Credentials:** Paste your bot token
- **Config:** Set DM policy and other channel-specific options
Click **Save**. The channel is immediately active — no gateway restart needed.
> **Important:** Only bind the lead agent to the channel. Specialists should not have their own channel bindings — they receive work exclusively through delegation.
Via config.json
Alternatively, add a binding to `config.json` and restart the gateway:
```json
{
"bindings": [
{
"agentId": "lead",
"match": {
"channel": "telegram"
}
}
]
}
```
```bash
./goclaw
```
## Step 5: Test delegation
Send your bot a message that requires both research and code:
> "What are the key differences between Rust's async model and Go's goroutines? Then write me a simple HTTP server in each."
The lead will:
1. Delegate the research question to `researcher`
2. Delegate the code request to `coder`
3. Run both in parallel (up to `maxConcurrent` limit, default 3 per link)
4. Synthesize and reply with both results
## Step 6: Monitor with the Task Board
Open **Teams → Assistant Team → Task Board** in the dashboard. The Kanban board shows delegation tasks in real time:
- **Columns:** To-Do, In-Progress, Done — tasks move automatically as specialists work
- **Real-time updates:** The board refreshes via delta updates, no manual reload needed
- **Task details:** Click any task to see the assigned agent, status, and output
- **Bulk operations:** Select multiple tasks with checkboxes for bulk delete or status changes
The Task Board is the best way to verify that delegation is working correctly and to debug issues when specialists don't respond as expected.
## Workspace scope
Each team has a workspace for files produced during task execution. The scope is configurable:
| Mode | Behavior | Best for |
|------|----------|----------|
| **Isolated** (default) | Each conversation gets its own folder (`teams/{teamID}/{chatID}/`) | Privacy between users, independent tasks |
| **Shared** | All members access one folder (`teams/{teamID}/`) | Collaborative tasks where agents build on each other's output |
Configure via team settings — in the dashboard, go to **Teams → your team → Settings** and set **Workspace Scope** to `shared` or `isolated`.
**Limits:** Max 10 MB per file, 100 files per scope.
## Progress notifications
Teams support automatic progress notifications with two modes:
| Mode | Behavior |
|------|----------|
| **Direct** | Progress updates sent directly to the chat channel — the user sees real-time status |
| **Leader** | Progress updates injected into the lead agent's session — the lead decides what to surface |
Enable in team settings: set **Progress Notifications** to on, then choose the **Escalation Mode**.
## How delegation works
```mermaid
flowchart TD
USER["User message"] --> LEAD["Lead agent"]
LEAD -->|"delegate to researcher"| RESEARCHER["Researcher specialist"]
LEAD -->|"delegate to coder"| CODER["Coder specialist"]
RESEARCHER -->|result| LEAD
CODER -->|result| LEAD
LEAD -->|"synthesized reply"| USER
```
The lead delegates via the `delegate` tool. Specialists run as sub-sessions and return their output. The lead sees all results and composes the final response.
## Common Issues
| Problem | Solution |
|---------|----------|
| "cannot delegate to open agents" | Specialists must be `agent_type: "predefined"`. Re-create them with the correct type. |
| Lead doesn't delegate | The lead needs to know about its team. Check that `TEAM.md` appears in the lead's context files (Dashboard → Agent → Files tab). Restart the gateway if missing. |
| Specialist summoning stuck | Check gateway logs for LLM errors. Summoning uses the configured provider — ensure it has a valid API key. |
| Users see specialist responses directly | Only the lead should be bound to the channel. Check Dashboard → Channels to verify specialists have no channel bindings. |
| Tasks not appearing on board | Ensure you're viewing the correct team. Delegation tasks appear automatically — if missing, check that the team was created correctly with all members. |
## What's Next
- [What Are Teams?](/teams-what-are-teams) — team concepts and architecture
- [Task Board](/teams-task-board) — full task board reference
- [Open vs. Predefined](/open-vs-predefined) — why specialists must be predefined
- [Customer Support](/recipe-customer-support) — predefined agent handling many users
---
# Customer Support
> A predefined agent that handles customer queries consistently across all users, with specialist escalation.
## Overview
This recipe sets up a customer support agent with a fixed personality (same for every user), per-user profiles, and a specialist escalation path. Unlike the personal assistant recipe, this agent is **predefined** — its SOUL.md and IDENTITY.md are shared across all users, ensuring consistent brand voice.
**What you need:**
- A working gateway (`./goclaw onboard`)
- Web dashboard access at `http://localhost:18790`
- At least one LLM provider configured
## Step 1: Create the support agent
Open the web dashboard and go to **Agents → Create Agent**:
- **Key:** `support`
- **Display name:** Support Assistant
- **Type:** Predefined
- **Provider / Model:** Choose your preferred provider and model
- **Description:** "Friendly customer support agent for Acme Corp. Patient, empathetic, solution-focused. Answers questions about our product, helps with account issues, and escalates complex technical problems to the engineering team. Always confirms resolution before closing. Responds in the user's language."
Click **Save**. The `description` field triggers **summoning** — the gateway uses the LLM to auto-generate SOUL.md and IDENTITY.md from your description.
Wait for the agent status to transition from `summoning` → `active`. You can watch this on the Agents list page.
Via API
```bash
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "support",
"display_name": "Support Assistant",
"agent_type": "predefined",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"other_config": {
"description": "Friendly customer support agent for Acme Corp. Patient, empathetic, solution-focused. Answers questions about our product, helps with account issues, and escalates complex technical problems to the engineering team. Always confirms resolution before closing. Responds in the user'\''s language."
}
}'
```
Poll status:
```bash
curl http://localhost:18790/v1/agents/support \
-H "Authorization: Bearer YOUR_TOKEN"
```
## Step 2: Write a manual SOUL.md (optional)
If you prefer to write the personality yourself instead of relying on summoning, go to **Dashboard → Agents → support → Files tab → SOUL.md** and edit inline:
```markdown
# Support Agent — SOUL.md
You are the support face of Acme Corp. Your core traits:
- **Patient**: Never rush a user. Repeat yourself if needed without frustration.
- **Empathetic**: Acknowledge problems before solving them. "That sounds frustrating — let me fix it."
- **Precise**: Give exact steps, not vague advice. If unsure, say so and escalate.
- **On-brand**: Friendly but professional. No slang. No emojis in formal replies.
You always confirm: "Does that solve the issue for you?" before ending.
```
Click **Save** when done.
Via API
```bash
curl -X PUT http://localhost:18790/v1/agents/support/files/SOUL.md \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary @- <<'EOF'
# Support Agent — SOUL.md
You are the support face of Acme Corp. Your core traits:
- **Patient**: Never rush a user. Repeat yourself if needed without frustration.
- **Empathetic**: Acknowledge problems before solving them. "That sounds frustrating — let me fix it."
- **Precise**: Give exact steps, not vague advice. If unsure, say so and escalate.
- **On-brand**: Friendly but professional. No slang. No emojis in formal replies.
You always confirm: "Does that solve the issue for you?" before ending.
EOF
```
## Step 3: Add a technical escalation specialist
Create a second predefined agent for complex issues. Go to **Agents → Create Agent**:
- **Key:** `tech-specialist`
- **Display name:** Technical Specialist
- **Type:** Predefined
- **Description:** "Senior technical support specialist. Handles complex API issues, integration problems, and bug reports. Methodical, detail-oriented, documents every issue with reproduction steps."
Click **Save** and wait for summoning to complete.
Then set up the escalation link: go to **Agents → support → Links tab → Add Link**:
- **Target agent:** `tech-specialist`
- **Direction:** Outbound
- **Description:** Escalate complex technical issues
- **Max concurrent:** 3
Click **Save**. The support agent can now delegate complex issues to the specialist.
Via API
```bash
# Create specialist
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "tech-specialist",
"display_name": "Technical Specialist",
"agent_type": "predefined",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"other_config": {
"description": "Senior technical support specialist. Handles complex API issues, integration problems, and bug reports. Methodical, detail-oriented, documents every issue with reproduction steps."
}
}'
# Create delegation link
curl -X POST http://localhost:18790/v1/agents/support/links \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"sourceAgent": "support",
"targetAgent": "tech-specialist",
"direction": "outbound",
"description": "Escalate complex technical issues",
"maxConcurrent": 3
}'
```
## Step 4: Configure per-user profiles
Because `support` is predefined, each user gets their own `USER.md` seeded on first chat. You can pre-populate profiles to give the agent context about who the user is.
Go to **Agents → support → Instances tab → select a user → Files → USER.md** and edit:
```markdown
# User Profile: Alice
- **Plan**: Enterprise (annual)
- **Company**: Acme Widgets Ltd
- **Joined**: 2023-08
- **Known issues**: Reported API rate limit problems in Nov 2024
- **Preferences**: Prefers technical explanations, not simplified answers
```
Via API
```bash
curl -X PUT http://localhost:18790/v1/agents/support/users/alice123/files/USER.md \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary @- <<'EOF'
# User Profile: Alice
- **Plan**: Enterprise (annual)
- **Company**: Acme Widgets Ltd
- **Joined**: 2023-08
- **Known issues**: Reported API rate limit problems in Nov 2024
- **Preferences**: Prefers technical explanations, not simplified answers
EOF
```
## Step 5: Restrict tools for support context
Support agents rarely need file system or shell access. Go to **Agents → support → Config tab** and configure tool permissions:
- **Allowed tools:** `web_fetch`, `web_search`, `memory_search`, `memory_save`, `delegate`
- Deny everything else
This limits the attack surface while keeping the agent functional for support tasks.
Via config.json
```json
{
"agents": {
"list": {
"support": {
"tools": {
"allow": ["web_fetch", "web_search", "memory_search", "memory_save", "delegate"]
}
}
}
}
}
```
Restart the gateway after config changes.
## Step 6: Connect a channel
Go to **Channels → Create Instance** in the dashboard:
- **Channel type:** Telegram (or Discord, Slack, Zalo OA, etc.)
- **Agent:** Select `support`
- **Credentials:** Paste your bot token
- **Config:** Set `dm_policy` to `open` so any customer can message the bot
Click **Save**. The channel is immediately active.
> **Tip:** For customer-facing bots, set `dm_policy: "open"` so users don't need to pair via browser first.
## File attachments
When the support agent uses `write_file` to generate a document (e.g., a troubleshooting report or account summary), the file is automatically delivered as a channel attachment to the user. No extra configuration needed — this works across all channel types.
## How context isolation works
```
support (predefined)
├── SOUL.md ← shared: same personality for all users
├── IDENTITY.md ← shared: same "who I am" for all users
├── AGENTS.md ← shared: operating instructions
│
├── User: alice123
│ ├── USER.md ← per-user: Alice's profile, tier, history
│ └── BOOTSTRAP.md ← first-run onboarding (clears itself)
│
└── User: bob456
├── USER.md ← per-user: Bob's profile
└── BOOTSTRAP.md
```
## Common Issues
| Problem | Solution |
|---------|----------|
| Agent personality differs between users | If the agent is `open`, each user shapes their own personality. Switch to `predefined` for shared SOUL.md. |
| USER.md not being seeded | First chat triggers seeding. If pre-populating via Instances tab, ensure you select the correct user. |
| Summoning failed, no SOUL.md | Check gateway logs for LLM errors during summoning. Manually write SOUL.md via the Files tab as shown in Step 2. |
| Support agent escalates too aggressively | Edit SOUL.md to add criteria: "Only delegate to tech-specialist when the user reports an API error code or integration failure." |
| Specialist not responding | Check the specialist's status is `active` and the delegation link exists (Agent → Links tab). |
## What's Next
- [Open vs. Predefined](/open-vs-predefined) — deep dive on context isolation
- [Summoning & Bootstrap](/summoning-bootstrap) — how personality is auto-generated
- [Team Chatbot](/recipe-team-chatbot) — coordinate multiple specialists via a team
- [Context Files](../agents/context-files.md) — full reference for SOUL.md, USER.md, and friends
---
# Code Review Agent
> An agent that reviews code using a Docker sandbox for safe execution and custom shell tools.
## Overview
This recipe creates a code review agent that can read files, run linters/tests inside a Docker sandbox, and use custom tools you define. The sandbox isolates all code execution from the host — no risk of malicious code affecting your system.
**Prerequisites:** A working gateway, Docker installed and running on the gateway host.
## Step 1: Build the sandbox image
GoClaw's sandbox uses a Docker container. Build the default image or use any existing one:
```bash
# Use the default image name expected by GoClaw
docker build -t goclaw-sandbox:bookworm-slim - <<'EOF'
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
git curl wget jq \
python3 python3-pip nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# Add your language runtimes and linters here
RUN npm install -g eslint typescript
RUN pip3 install ruff pyflakes --break-system-packages
EOF
```
## Step 2: Create the code review agent
You can create the agent via **Dashboard → Agents → Create Agent** (key: `code-reviewer`, type: Predefined, paste the description below), or via the API:
```bash
curl -X POST http://localhost:18790/v1/agents \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent_key": "code-reviewer",
"display_name": "Code Reviewer",
"agent_type": "predefined",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"other_config": {
"description": "Expert code reviewer. Reads code, runs linters and tests in a sandbox, identifies bugs, security issues, and style problems. Gives actionable, prioritized feedback. Explains the why behind each suggestion."
}
}'
```
## Step 3: Enable the sandbox
Add sandbox config to `config.json` under the agent's entry:
```json
{
"agents": {
"list": {
"code-reviewer": {
"sandbox": {
"mode": "all",
"image": "goclaw-sandbox:bookworm-slim",
"workspace_access": "rw",
"scope": "session",
"memory_mb": 512,
"cpus": 1.0,
"timeout_sec": 120,
"network_enabled": false,
"read_only_root": true
}
}
}
}
}
```
**Sandbox mode options:**
- `"off"` — no sandbox, exec runs on host (default)
- `"non-main"` — sandbox only for subagent/delegated runs
- `"all"` — all exec and file operations go through Docker
`network_enabled: false` prevents code from making outbound connections. `read_only_root: true` means only the mounted workspace is writable.
Restart the gateway after updating config.
## Step 4: Create a custom linting tool
Custom tools run shell commands with `{{.param}}` template substitution. All values are shell-escaped automatically.
```bash
curl -X POST http://localhost:18790/v1/tools/custom \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "run_linter",
"description": "Run a linter on a file and return the output. Supports Python (ruff), JavaScript/TypeScript (eslint), and Go (go vet).",
"command": "case {{.language}} in python) ruff check {{.file}} ;; js|ts) eslint {{.file}} ;; go) go vet {{.file}} ;; *) echo \"Unsupported language: {{.language}}\" ;; esac",
"timeout_seconds": 30,
"parameters": {
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "Path to the file to lint (relative to workspace)"
},
"language": {
"type": "string",
"enum": ["python", "js", "ts", "go"],
"description": "Programming language of the file"
}
},
"required": ["file", "language"]
}
}'
```
The tool runs inside the sandbox when `sandbox.mode` is `"all"`. The `{{.file}}` and `{{.language}}` placeholders are replaced with shell-escaped values from the LLM's tool call.
## Step 5: Add a test runner tool
```bash
curl -X POST http://localhost:18790/v1/tools/custom \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "run_tests",
"description": "Run tests for a project directory and return results.",
"command": "cd {{.dir}} && case {{.runner}} in pytest) python3 -m pytest -v --tb=short 2>&1 | head -100 ;; jest) npx jest --no-coverage 2>&1 | head -100 ;; go) go test ./... 2>&1 | head -100 ;; *) echo \"Unknown runner: {{.runner}}\" ;; esac",
"timeout_seconds": 60,
"parameters": {
"type": "object",
"properties": {
"dir": {
"type": "string",
"description": "Project directory relative to workspace"
},
"runner": {
"type": "string",
"enum": ["pytest", "jest", "go"],
"description": "Test runner to use"
}
},
"required": ["dir", "runner"]
}
}'
```
## Step 6: Write the agent's SOUL.md
Give the reviewer a clear review methodology. Go to **Dashboard → Agents → code-reviewer → Files tab → SOUL.md** and paste:
```markdown
# Code Reviewer SOUL
You are a thorough, pragmatic code reviewer. Your process:
1. **Read first** — understand what the code is trying to do before judging it
2. **Run tools** — lint the files, run tests if available
3. **Prioritize** — label findings as Critical / Major / Minor / Nitpick
4. **Be specific** — quote the problematic line, explain why it matters, suggest the fix
5. **Be kind** — acknowledge good decisions, not just problems
Never block on style alone. Focus on correctness, security, and maintainability.
```
Via API
```bash
curl -X PUT http://localhost:18790/v1/agents/code-reviewer/files/SOUL.md \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary @- <<'EOF'
# Code Reviewer SOUL
You are a thorough, pragmatic code reviewer. Your process:
1. **Read first** — understand what the code is trying to do before judging it
2. **Run tools** — lint the files, run tests if available
3. **Prioritize** — label findings as Critical / Major / Minor / Nitpick
4. **Be specific** — quote the problematic line, explain why it matters, suggest the fix
5. **Be kind** — acknowledge good decisions, not just problems
Never block on style alone. Focus on correctness, security, and maintainability.
EOF
```
## Step 7: Test the agent
Drop a file into the agent's workspace and ask for a review. You can chat via **Dashboard → Agents → code-reviewer** and use the chat interface, or via the API:
```bash
# Write a test file to the workspace
curl -X PUT http://localhost:18790/v1/agents/code-reviewer/files/workspace/review_me.py \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: text/plain" \
--data-binary 'import os; password = "hardcoded_secret"; print(os.system(f"echo {password}"))'
# Chat with the agent
curl -X POST http://localhost:18790/v1/chat \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "X-GoClaw-User-Id: admin" \
-H "Content-Type: application/json" \
-d '{
"agent": "code-reviewer",
"message": "Please review the file review_me.py in the workspace. Run the linter and report all issues."
}'
```
## How the sandbox works
```mermaid
flowchart LR
AGENT["Agent decides\nto run linter"] --> TOOL["run_linter tool\ncalled by LLM"]
TOOL --> SANDBOX["Docker container\ngoclaw-sandbox:bookworm-slim"]
SANDBOX --> CMD["sh -c 'ruff check file.py'"]
CMD --> OUTPUT["Stdout/stderr\ncaptured"]
OUTPUT --> AGENT
```
All `exec`, `read_file`, `write_file`, and `list_files` calls go through the container when `mode: "all"`. The workspace directory is bind-mounted at the configured `workspace_access` level.
## Alternative: ACP provider for external agents
If your code review workflow uses an external coding agent (Claude Code, Codex, Gemini CLI), you can configure an [ACP (Agent Client Protocol)](/provider-acp) provider instead of OpenRouter. ACP connects to external agents via JSON-RPC 2.0, letting them serve as the LLM backend for your code-reviewer agent.
## MCP tool performance
If your code-reviewer uses many MCP tools, GoClaw lazily activates deferred tools — they load on first call rather than at startup. This reduces initial overhead for agents with large MCP server configurations.
## Common Issues
| Problem | Solution |
|---------|----------|
| "sandbox: docker not found" | Ensure Docker is installed and the `docker` binary is on `PATH` for the gateway process. |
| Container starts but linter missing | Add your tools to the Docker image. Rebuild and restart the gateway. |
| Exec timeout | Increase `timeout_sec` in sandbox config. Default is 300s but complex test suites may need more. |
| Files not visible inside sandbox | Workspace is mounted at `workspace_access: "rw"`. Ensure files are written to the agent's workspace path. |
| Custom tool name collides | Tool names must be unique. Use `GET /v1/tools/builtin` to see reserved names. |
## What's Next
- [Multi-Channel Setup](/recipe-multi-channel) — expose this agent on Telegram and WebSocket
- [Team Chatbot](/recipe-team-chatbot) — add the reviewer as a specialist in a team
- [Tools Reference](/cli-commands) — full built-in tool list and policy options
---
# Multi-Channel Setup
> Put the same agent on Telegram, Discord, and WebSocket simultaneously.
## Overview
GoClaw runs multiple channels from one gateway process. A single agent can receive messages from Telegram, Discord, and direct WebSocket clients at the same time — each channel has its own session scope, so conversations stay isolated per channel and user.
**What you need:**
- A working gateway with at least one agent created
- Web dashboard access at `http://localhost:18790`
- Bot tokens for each messaging platform
## Step 1: Gather your tokens
You need a bot token for each messaging platform:
**Telegram:** Message [@BotFather](https://t.me/BotFather) → `/newbot` → copy token
**Discord:** [discord.com/developers](https://discord.com/developers/applications) → New Application → Bot → Add Bot → copy token. Enable **Message Content Intent** under Privileged Gateway Intents.
WebSocket needs no external token — clients authenticate with your gateway token.
## Step 2: Create channel instances
Open the web dashboard and go to **Channels → Create Instance**. Create one instance per platform:
**Telegram:**
- **Channel type:** Telegram
- **Name:** `main-telegram`
- **Agent:** Select your agent
- **Credentials:** Paste the bot token from @BotFather
- **Config:** Set `dm_policy` to `pairing` (recommended) or `open`
Click **Save**.
**Discord:**
- **Channel type:** Discord
- **Name:** `main-discord`
- **Agent:** Select the same agent
- **Credentials:** Paste the Discord bot token
- **Config:** Set `dm_policy` to `open`, `require_mention` to `true`
Click **Save**.
Both channels are immediately active — no gateway restart needed. WebSocket is built into the gateway and needs no instance creation.
On startup you should see log lines like:
```
channel=telegram status=connected bot=@YourBotName
channel=discord status=connected guild_count=2
gateway status=listening addr=0.0.0.0:18790
```
Via config.json
Add all channel configs to `config.json`. Secrets (tokens) go in `.env.local` — not in the config file.
`config.json`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "",
"dm_policy": "pairing",
"group_policy": "open",
"require_mention": true,
"reaction_level": "minimal"
},
"discord": {
"enabled": true,
"token": "",
"dm_policy": "open",
"group_policy": "open",
"require_mention": true,
"history_limit": 50
}
},
"gateway": {
"host": "0.0.0.0",
"port": 18790,
"token": ""
}
}
```
`.env.local` (secrets only — never commit this file):
```bash
export GOCLAW_TELEGRAM_TOKEN="123456:ABCDEFGHIJKLMNOPQRSTUVWxyz"
export GOCLAW_DISCORD_TOKEN="your-discord-bot-token"
export GOCLAW_GATEWAY_TOKEN="your-gateway-token"
export GOCLAW_POSTGRES_DSN="postgres://user:pass@localhost:5432/goclaw"
```
GoClaw reads channel tokens from environment variables when the `token` field in config is empty.
Add bindings to route messages to your agent:
```json
{
"bindings": [
{
"agentId": "my-assistant",
"match": { "channel": "telegram" }
},
{
"agentId": "my-assistant",
"match": { "channel": "discord" }
}
]
}
```
Start the gateway:
```bash
source .env.local && ./goclaw
```
## Step 3: Connect a WebSocket client
WebSocket is built into the gateway — no extra setup needed. Connect and authenticate:
```javascript
const ws = new WebSocket('ws://localhost:18790/ws');
// First frame must be connect
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'req',
id: '1',
method: 'connect',
params: {
token: 'your-gateway-token',
user_id: 'web-user-alice'
}
}));
};
// Send a chat message
function chat(message) {
ws.send(JSON.stringify({
type: 'req',
id: String(Date.now()),
method: 'chat',
params: {
agent: 'my-assistant',
message: message
}
}));
}
// Listen for responses and streaming chunks
ws.onmessage = (e) => {
const frame = JSON.parse(e.data);
if (frame.type === 'event' && frame.event === 'chunk') {
process.stdout.write(frame.payload.text);
}
if (frame.type === 'res' && frame.method === 'chat') {
console.log('\n[done]');
}
};
```
See [WebSocket Channel](/channel-websocket) for the full protocol reference.
## Step 4: Verify cross-channel isolation
Sessions are isolated by channel and user by default (`dm_scope: "per-channel-peer"`). This means:
- Alice on Telegram and Alice on Discord have **separate** conversation histories
- The agent treats them as different users
Verify isolation in the dashboard: go to **Sessions** and filter by agent — you should see separate sessions for each channel.
If you want a single session across channels for the same user, set `dm_scope: "per-peer"` in `config.json`:
```json
{
"sessions": {
"dm_scope": "per-peer"
}
}
```
This shares conversation history when the same `user_id` connects from any channel.
## Telegram message handling
Telegram has a 4096-character message limit. GoClaw handles long responses automatically:
- Long messages are split into multiple parts at natural boundaries (paragraphs, code blocks)
- HTML formatting is attempted first for rich output
- If HTML parsing fails, the message falls back to plain text
- No configuration needed — this is fully automatic
## Channel comparison
| Feature | Telegram | Discord | WebSocket |
|---------|----------|---------|-----------|
| Setup | @BotFather token | Developer Portal token | None (use gateway token) |
| DM policy default | `pairing` | `open` | Auth via gateway token |
| Group/server support | Yes | Yes | N/A |
| Streaming | Optional (`dm_stream`) | Via message edits | Native (chunk events) |
| Mention required in groups | Yes (default) | Yes (default) | N/A |
| Custom client | No | No | Yes |
## Restrict tools per channel
You can allow different tool sets per channel. Go to **Agents → your agent → Config tab** and configure per-channel tool policies.
Via config.json
```json
{
"agents": {
"list": {
"my-assistant": {
"tools": {
"byProvider": {
"telegram": { "deny": ["exec", "write_file"] },
"discord": { "deny": ["exec", "write_file"] }
}
}
}
}
}
}
```
WebSocket clients (usually developers or internal tools) can keep full tool access.
## File attachments
When the agent uses `write_file` to generate a file, it is automatically delivered as a channel attachment. This works across Telegram, Discord, and other supported channels — no extra configuration needed.
## Common Issues
| Problem | Solution |
|---------|----------|
| Telegram bot not responding | Check `dm_policy`. Default is `"pairing"` — complete browser pairing first, or set `"open"` for testing. |
| Discord bot offline in server | Verify the bot has been added to the server via OAuth2 URL Generator with `bot` scope and `Send Messages` permission. |
| WebSocket connect rejected | Ensure `token` in your connect frame matches `GOCLAW_GATEWAY_TOKEN`. Empty token gives viewer-only role. |
| Messages routing to wrong agent | Check channel instance agent assignment in Dashboard → Channels. First matching binding wins when using config.json. |
| Same user gets different sessions on Telegram vs Discord | Expected with default `dm_scope: "per-channel-peer"`. Set `"per-peer"` to share sessions across channels. |
## What's Next
- [Telegram Channel](/channel-telegram) — full Telegram config reference including groups, topics, and STT
- [Discord Channel](/channel-discord) — Discord gateway intents and streaming setup
- [WebSocket Channel](/channel-websocket) — full RPC protocol reference
- [Personal Assistant](/recipe-personal-assistant) — single-channel starting point
---
# Gallery
> Real-world examples and deployment scenarios for GoClaw.
## Overview
This page showcases how GoClaw can be deployed in different scenarios — from a personal Telegram bot to a multi-tenant team platform. Use these as starting points for your own setup.
## Deployment Scenarios
### Personal AI Assistant
A single agent on Telegram for personal use.
```jsonc
{
"agents": {
"defaults": {
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4-5-20250929",
"agent_type": "open",
"memory": { "enabled": true }
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "" // from @BotFather
}
}
}
```
**What you get:** A personal assistant that remembers your preferences, searches the web, runs code, and manages files — all through Telegram.
### Team Coding Bot
A predefined agent shared across a development team on Discord.
```jsonc
{
"agents": {
"list": {
"code-bot": {
"agent_type": "predefined",
"provider": "anthropic",
"model": "claude-opus-4-6",
"tools": { "profile": "coding" },
"temperature": 0.3,
"max_tool_iterations": 50
}
}
},
"channels": {
"discord": {
"enabled": true,
"token": "" // from Discord Developer Portal
}
}
}
```
**What you get:** A shared coding assistant with consistent personality (predefined), low temperature for precise code, and extended tool iterations for complex tasks. Each team member gets personal context via USER.md.
### Multi-Channel Support Bot
One agent available on Telegram, Discord, and WebSocket simultaneously.
```jsonc
{
"agents": {
"list": {
"support-bot": {
"agent_type": "predefined",
"tools": { "profile": "messaging" }
}
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "" // Telegram bot token
},
"discord": {
"enabled": true,
"token": "" // Discord bot token
}
}
}
```
**What you get:** Consistent support experience across channels. Users on Telegram and Discord talk to the same agent with the same knowledge base.
### Agent Team with Delegation
A lead agent that delegates specialized tasks to other agents.
```jsonc
{
"agents": {
"list": {
"lead": {
"provider": "anthropic",
"model": "claude-opus-4-6"
},
"researcher": {
"provider": "openrouter",
"model": "google/gemini-2.5-pro",
"tools": { "profile": "coding" }
},
"writer": {
"provider": "anthropic",
"model": "claude-sonnet-4-5-20250929",
"tools": { "profile": "messaging" }
}
}
}
}
```
**What you get:** The lead agent coordinates work, delegating research to a Gemini-powered agent and writing tasks to a Claude-powered agent. Each uses the best model for its role.
## Community
Have a GoClaw deployment you'd like to showcase? Open a pull request to add it here.
## What's Next
- [What Is GoClaw](/what-is-goclaw) — Start from the beginning
- [Quick Start](/quick-start) — Get running in 5 minutes
- [Configuration](/configuration) — Full config reference
---
# CLI Commands
> Complete reference for every `goclaw` command, subcommand, and flag.
## Overview
The `goclaw` binary is a single executable that starts the gateway and provides management subcommands. Global flags apply to all commands.
```bash
goclaw [global flags] [subcommand] [flags] [args]
```
**Global flags**
| Flag | Default | Description |
|------|---------|-------------|
| `--config ` | `config.json` | Config file path. Also read from `$GOCLAW_CONFIG` |
| `-v`, `--verbose` | false | Enable debug logging |
| `--server ` | — | Gateway server URL override for HTTP-backed commands (traces, skills, etc.). Falls back to `$GOCLAW_SERVER`, then `$GOCLAW_GATEWAY_URL` |
| `--token ` | — | Gateway bearer token override. Falls back to `$GOCLAW_GATEWAY_TOKEN` |
## `version`
Print version and protocol number.
```bash
goclaw version
# goclaw v1.2.0 (protocol 3)
```
---
## `onboard`
Interactive setup wizard — configure provider, model, gateway port, channels, features, and database.
```bash
goclaw onboard
```
Steps:
1. AI provider + API key (OpenRouter, Anthropic, OpenAI, Groq, DeepSeek, Gemini, Mistral, xAI, MiniMax, Cohere, Perplexity, Claude CLI, Custom)
2. Gateway port (default: 18790)
3. Channels (Telegram, Zalo OA, Feishu/Lark)
4. Features (memory, browser automation)
5. TTS provider
6. PostgreSQL DSN
Saves `config.json` (no secrets) and `.env.local` (secrets only).
**Environment-based auto-onboard** — if the required env vars are set, the wizard is skipped and setup runs non-interactively (useful for Docker/CI).
A TUI-based onboard is available when the terminal supports it (`tui_onboard.go`). Falls back to plain interactive mode automatically.
---
## `agent`
Manage agents — add, list, delete, and chat.
### `agent list`
List all configured agents.
```bash
goclaw agent list
goclaw agent list --json
```
| Flag | Description |
|------|-------------|
| `--json` | Output as JSON |
### `agent add`
Interactive wizard to add a new agent.
```bash
goclaw agent add
```
Prompts: agent name, display name, provider (or inherit), model (or inherit), workspace directory. Saves to `config.json`. Restart gateway to activate.
### `agent delete`
Delete an agent from config.
```bash
goclaw agent delete
goclaw agent delete researcher --force
```
| Flag | Description |
|------|-------------|
| `--force` | Skip confirmation prompt |
Also removes bindings referencing the deleted agent.
### `agent chat`
Send a one-shot message to an agent via the running gateway.
```bash
goclaw agent chat "What files are in the workspace?"
goclaw agent chat --agent researcher "Summarize today's news"
goclaw agent chat --session my-session "Continue where we left off"
```
| Flag | Default | Description |
|------|---------|-------------|
| `--agent ` | `default` | Target agent ID |
| `--session ` | auto | Session key to resume |
| `--json` | false | Output response as JSON |
---
## `migrate`
Database migration management. All subcommands require `GOCLAW_POSTGRES_DSN`.
```bash
goclaw migrate [--migrations-dir ]
```
| Flag | Description |
|------|-------------|
| `--migrations-dir ` | Path to migrations directory (default: `./migrations`) |
### `migrate up`
Apply all pending migrations.
```bash
goclaw migrate up
```
After SQL migrations, runs pending Go-based data hooks.
### `migrate down`
Roll back migrations.
```bash
goclaw migrate down # roll back 1 step
goclaw migrate down -n 3 # roll back 3 steps
```
| Flag | Default | Description |
|------|---------|-------------|
| `-n`, `--steps ` | 1 | Number of steps to roll back |
### `migrate version`
Show current migration version.
```bash
goclaw migrate version
# version: 10, dirty: false
```
### `migrate force `
Force-set the migration version without applying SQL (use after manual fixes).
```bash
goclaw migrate force 9
```
### `migrate goto `
Migrate to a specific version (up or down).
```bash
goclaw migrate goto 5
```
### `migrate drop`
**DANGEROUS.** Drop all tables.
```bash
goclaw migrate drop
```
---
## `upgrade`
Upgrade database schema and run data migrations. Idempotent — safe to run multiple times.
```bash
goclaw upgrade
goclaw upgrade --dry-run # preview without applying
goclaw upgrade --status # show current upgrade status
```
| Flag | Description |
|------|-------------|
| `--dry-run` | Show what would be done without applying |
| `--status` | Show current schema version and pending hooks |
Gateway startup also checks schema compatibility. Set `GOCLAW_AUTO_UPGRADE=true` to auto-upgrade on startup.
---
## `backup`
Back up the GoClaw database and config to an archive file.
```bash
goclaw backup
goclaw backup --output /path/to/backup.tar.gz
```
| Flag | Description |
|------|-------------|
| `--output ` | Output archive path (default: timestamped file in current dir) |
---
## `restore`
Restore from a backup archive.
```bash
goclaw restore /path/to/backup.tar.gz
```
---
## `tenant_backup`
Back up a single tenant's data.
```bash
goclaw tenant_backup --tenant
goclaw tenant_backup --tenant --output /path/to/backup.tar.gz
```
---
## `tenant_restore`
Restore a single tenant from a backup archive.
```bash
goclaw tenant_restore --tenant /path/to/backup.tar.gz
```
---
## `doctor`
Check system environment and configuration health.
```bash
goclaw doctor
```
Checks: binary version, config file, database connectivity, schema version, providers, channels, external binaries (docker, curl, git), workspace directory. Prints a pass/fail summary for each check.
Provider rows with an empty `display_name` now render the canonical `name` instead of a blank line.
---
## `pairing`
Manage device pairing — approve, list, and revoke paired devices.
### `pairing list`
List pending pairing requests and paired devices.
```bash
goclaw pairing list
```
### `pairing approve [code]`
Approve a pairing code. Interactive selection if no code given.
```bash
goclaw pairing approve # interactive picker
goclaw pairing approve ABCD1234 # approve specific code
```
### `pairing revoke `
Revoke a paired device.
```bash
goclaw pairing revoke telegram 123456789
```
---
## `sessions`
View and manage chat sessions. Requires gateway to be running.
### `sessions list`
List all sessions.
```bash
goclaw sessions list
goclaw sessions list --agent researcher
goclaw sessions list --json
```
| Flag | Description |
|------|-------------|
| `--agent ` | Filter by agent ID |
| `--json` | Output as JSON |
### `sessions delete `
Delete a session.
```bash
goclaw sessions delete "telegram:123456789"
```
### `sessions reset `
Clear session history while keeping the session record.
```bash
goclaw sessions reset "telegram:123456789"
```
---
## `traces`
Inspect agent execution traces and run timelines through the running gateway. All `traces` subcommands are HTTP-backed — they connect to the gateway resolved from `--server` / `$GOCLAW_SERVER` / `$GOCLAW_GATEWAY_URL` and authenticate with `--token` / `$GOCLAW_GATEWAY_TOKEN`.
| Persistent flag | Default | Description |
|------|---------|-------------|
| `-o`, `--output ` | `table` | Output format |
```bash
goclaw traces list --status error --limit 20
goclaw traces get -o json
goclaw traces export --file trace.json.gz
goclaw traces follow --session --since 2026-06-12T01:00:00Z
goclaw traces timeline
# remote gateway:
goclaw --server https://goclaw.example.com --token "$GOCLAW_GATEWAY_TOKEN" traces get -o json
```
### `traces list`
List traces with filtering and full-text search.
```bash
goclaw traces list
goclaw traces list -q "payment" --has-tool-calls true --limit 50
```
| Flag | Description |
|------|-------------|
| `-q`, `--query ` | Search trace text, IDs, labels, and span previews |
| `--agent-id ` | Filter by agent UUID |
| `--user ` | Filter by user ID (admin callers) |
| `--session ` | Filter by session key |
| `--status ` | Filter by trace status (`running`, `completed`, `error`, `cancelled`) |
| `--channel ` | Filter by raw channel |
| `--agent ` | Search agent display name or key |
| `--channel-query ` | Search channel instance labels |
| `--tool ` | Search span tool names |
| `--from ` | Start-time lower bound (inclusive) |
| `--to ` | Start-time upper bound (exclusive) |
| `--since ` | Alias for `--from` |
| `--until ` | Alias for `--to` |
| `--has-tool-calls ` | Only traces with/without tool calls |
| `--min-input-tokens ` | Minimum input tokens |
| `--max-input-tokens ` | Maximum input tokens |
| `--min-output-tokens ` | Minimum output tokens |
| `--max-output-tokens ` | Maximum output tokens |
| `--min-tool-calls ` | Minimum tool-call count |
| `--max-tool-calls ` | Maximum tool-call count |
| `--limit ` | Page size (max 200) |
| `--offset ` | Pagination offset |
### `traces get `
Get trace details with spans. Takes exactly one trace ID.
```bash
goclaw traces get
goclaw traces get -o json
```
### `traces export `
Export a gzipped trace tree. Takes exactly one trace ID.
```bash
goclaw traces export # writes trace--.json.gz
goclaw traces export --file trace.json.gz
goclaw traces export --file - # gzip to stdout
goclaw traces export -o json # decompressed JSON to stdout
```
| Flag | Description |
|------|-------------|
| `--file ` | Write gzip export to file (use `-` for stdout). Default writes `trace--.json.gz` |
### `traces follow`
Poll trace changes for one session or agent. **Requires `--session` OR `--agent-id`.**
```bash
goclaw traces follow --session --since 2026-06-12T01:00:00Z
goclaw traces follow --agent-id --include-spans
```
| Flag | Description |
|------|-------------|
| `--session ` | Filter by session key |
| `--agent-id ` | Filter by agent UUID |
| `--user ` | Filter by user ID (admin callers) |
| `--status ` | Filter by trace status |
| `--channel ` | Filter by raw channel |
| `--since ` | RFC3339 lower bound for changed traces |
| `--limit ` | Page size (max 200) |
| `--include-spans` | Include spans grouped by trace ID |
### `traces timeline `
Show the persisted run timeline linked to a trace. Resolves the trace's `run_id`, then queries the run archive. Takes exactly one trace ID.
```bash
goclaw traces timeline
goclaw traces timeline --limit 100 --offset 0
```
| Flag | Description |
|------|-------------|
| `--limit ` | Page size (max 500) |
| `--offset ` | Pagination offset |
---
## `cron`
Manage scheduled cron jobs. Requires gateway to be running.
### `cron list`
List cron jobs.
```bash
goclaw cron list
goclaw cron list --all # include disabled jobs
goclaw cron list --json
```
| Flag | Description |
|------|-------------|
| `--all` | Include disabled jobs |
| `--json` | Output as JSON |
### `cron delete `
Delete a cron job.
```bash
goclaw cron delete 3f5a8c2b
```
### `cron toggle `
Enable or disable a cron job.
```bash
goclaw cron toggle 3f5a8c2b true
goclaw cron toggle 3f5a8c2b false
```
---
## `config`
View and manage configuration.
### `config show`
Display current configuration with secrets redacted.
```bash
goclaw config show
```
### `config path`
Print the config file path being used.
```bash
goclaw config path
# /home/user/goclaw/config.json
```
### `config validate`
Validate the config file syntax and structure.
```bash
goclaw config validate
# Config at config.json is valid.
```
---
## `channels`
List and manage messaging channels.
### `channels list`
List configured channels and their status.
```bash
goclaw channels list
goclaw channels list --json
```
| Flag | Description |
|------|-------------|
| `--json` | Output as JSON |
Output columns: `CHANNEL`, `ENABLED`, `CREDENTIALS` (ok/missing).
---
## `providers`
Manage LLM providers (requires running gateway).
### `providers list`
List configured providers.
```bash
goclaw providers list
goclaw providers list --json
goclaw providers list --models
```
| Flag | Description |
|------|-------------|
| `--json` | Output as JSON |
| `--models` | Also show available models per provider |
Shows provider name, type, enabled status, and whether an API key is configured.
### `providers add`
Add a new provider (interactive).
```bash
goclaw providers add
```
Interactive prompts for provider type, name, API key, and base URL. Offers to verify connectivity after creation.
### `providers update `
Update a provider's name or API key.
```bash
goclaw providers update
```
### `providers delete