Zombie Brains Docs
The Cognitive OS for AI — context that won't stay dead.
Overview
Zombie Brains is the Cognitive OS for AI — persistent memory, shared knowledge, custom skills, and training data generation across sessions and platforms. Connect via MCP (Claude, ChatGPT, Cursor) or REST API (scripts, bots, SaaS integrations). Memories are stored, embedded, linked, and retrieved using a neuroscience-inspired cognitive architecture — no LLM on our side.
Base URL: https://mcp.zombie.codes (also: https://api.zombie.codes)
Connect Your AI
Zombie Brains works with any AI that supports the Model Context Protocol (MCP). Pick your platform below:
Claude (Anthropic)
- Open Claude at claude.ai or the Claude desktop/mobile app
- Go to Settings → Connectors → Add
- Enter the connector URL:
https://mcp.zombie.codes - Click Connect — you'll be redirected to sign in with Google, GitHub, or Microsoft
- Once connected, start any conversation and say: "Always store important information to my brain."
Every new conversation, tell Claude again to kick things off. Your brain loads automatically — Claude just needs the nudge to keep storing.
Cursor
- Open Cursor and go to Settings → MCP
- Click Add MCP Server
- Set the type to SSE (Server-Sent Events)
- Enter the URL:
https://mcp.zombie.codes - Save and authorize when prompted
- In any chat or Composer session, tell Cursor: "Always store important information to my brain."
Cursor will have access to all Zombie Brains tools — load_brain, add_memory, search_memory, brain_overview, log_session, skills, manage, and read_document. Your coding decisions and architecture choices persist across projects.
Windsurf (Codeium)
- Open Windsurf and go to Settings → MCP Servers
- Click Add Server
- Enter the URL:
https://mcp.zombie.codes - Set the transport to SSE
- Save and authorize when prompted
- In Cascade, tell it: "Always store important information to my brain."
Windsurf's Cascade flow will automatically load your brain context at the start of each session.
ChatGPT (OpenAI)
- Open ChatGPT at chatgpt.com
- Go to Settings → Connected Apps or look for the MCP option in your conversation
- Add a new MCP connection with the URL:
https://mcp.zombie.codes - Authorize when prompted
- Tell ChatGPT: "Always store important information to my brain."
MCP support in ChatGPT may vary by plan and rollout status. If you don't see the option, check OpenAI's documentation for the latest availability.
Other MCP-Compatible Tools
Any tool that supports MCP can connect to Zombie Brains. The general process:
- Find the MCP server or tool server configuration in your AI tool's settings
- Add an SSE-type server with URL:
https://mcp.zombie.codes - Authorize via the OAuth popup (Google, GitHub, or Microsoft)
- Start using it — tell your AI: "Always store important information to my brain."
Compatible tools include: Continue.dev, Cline, Zed, VS Code (via MCP extensions), and any tool implementing the Model Context Protocol spec.
Authentication
Four auth methods, each scoped to different capabilities:
| Key Prefix | Name | Scope | Use Case |
|---|---|---|---|
zp_ | Platform Key | All brains under your platform | Your backend server — admin ops + operational (with X-Brain-Id) |
cm_ | API Key | Single brain | MCP connections, AI sessions, direct memory operations |
zwh_ | Webhook Token | Single connector | Ingest-only (/v1/ingest). Auto-generated with connectors. |
| OAuth | OAuth Token | User's accessible brains | Brain management, admin dashboard. Via Auth0 flow. |
Platform Key (embedded infrastructure)
# Admin operations — no X-Brain-Id needed
curl -X POST https://mcp.zombie.codes/v1/platform/brains \
-H "Authorization: Bearer zp_a1b2c3d4e5f6g7h8..." \
-H "Content-Type: application/json" \
-d '{"name": "Author: Jane"}'
# Operational endpoints — requires X-Brain-Id or brain_id param
curl "https://mcp.zombie.codes/v1/memory/search?q=dragon+magic" \
-H "Authorization: Bearer zp_a1b2c3d4e5f6g7h8..." \
-H "X-Brain-Id: 550e8400-e29b-41d4-a716-446655440000"
API Key (memory operations)
curl https://mcp.zombie.codes/v1/memory/search?q=architecture \
-H "Authorization: Bearer cm_a1b2c3d4e5f6g7h8i9j0..."
Full access to memory read/write for the associated brain. Get keys from the admin dashboard or generate via the Platform API.
Webhook Token (ingest only)
curl -X POST https://mcp.zombie.codes/v1/ingest \
-H "Authorization: Bearer zwh_a587259ed0214497b8b200da..." \
-H "Content-Type: application/json" \
-d '{"content": "Meeting notes..."}'
Connector-scoped. Can only access /v1/ingest — returns 403 on all other endpoints. Generated when creating a connector.
OAuth Token (brain management)
curl https://mcp.zombie.codes/v1/brains \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
Required for brain management endpoints (create/delete brains, invite members, analytics). Obtained via the Auth0 OAuth flow — the same token the MCP connection uses.
Quick Start
Store your first memory in 30 seconds:
# 1. Store a memory
curl -X POST https://mcp.zombie.codes/v1/memory/add \
-H "Authorization: Bearer cm_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "We chose Postgres over Neo4j because graph sizes per brain are small and recursive CTEs handle traversal",
"type": "decision",
"salience": "elevated"
}'
# 2. Search for it
curl "https://mcp.zombie.codes/v1/memory/search?q=database+choice" \
-H "Authorization: Bearer cm_YOUR_API_KEY"
Store a Memory
POST
POST /v1/memory/add
Content-Type: application/json
Authorization: Bearer cm_YOUR_API_KEY
{
"content": "We decided to use Thompson Sampling for play card selection because it balances exploration vs exploitation",
"type": "decision",
"salience": "elevated",
"target_brain_id": "11c6dc92-4d25-...",
"triggers": [
{
"condition": "working on play card selection or A/B testing",
"reason": "Thompson Sampling is the chosen algorithm — don't switch to UCB without re-evaluating"
}
]
}
Response:
{
"id": "a1b2c3d4-...",
"type": "decision",
"salience": "elevated",
"topics": ["Thompson", "Sampling", "play", "card", "selection"],
"auto_linked_to": ["f5e6d7c8-..."],
"message": "Stored elevated decision. This connects to 1 related memories.",
"routing_check": {
"stored_in": { "brain": "Outbound Bot Brain", "confidence": 0.48 },
"alternatives": [
{ "brain": "AI Org Brain", "confidence": 0.42 },
{ "brain": "Engineering Brain", "confidence": 0.31 }
]
}
}
| Field | Type | Required | Description |
|---|---|---|---|
content | string | yes | The memory content in natural language |
type | string | no | decision / constraint / fact / preference / observation (auto-inferred) |
salience | string | no | normal / elevated / critical (auto-inferred) |
target_brain_id | uuid | no | Route to a specific brain. Omit for personal brain. |
triggers | array | no | Conditions that should proactively surface this memory |
session_id | string | no | Session ID from load_brain (enables reconsolidation) |
Search Memories
GET
GET /v1/memory/search?q=database+architecture&limit=5&offset=0&source=auto_ingested
Authorization: Bearer cm_YOUR_API_KEY
Response:
{
"memories": [
{
"id": "a1b2c3d4-...",
"content": "We chose Postgres over Neo4j because...",
"type": "decision",
"salience": "elevated",
"relevance_score": 0.05,
"semantic_similarity": 0.72,
"stored_by": "Robert Sellman",
"brain_name": "Engineering Brain",
"topics": ["Postgres", "Neo4j", "database"],
"triggers": [...]
}
],
"graph_connections": [...],
"pagination": { "offset": 0, "limit": 5, "total_available": 12, "has_more": true }
}
| Param | Type | Default | Description |
|---|---|---|---|
q | string | — | Search query (required) |
limit | int | 10 | Results per page (max 20) |
offset | int | 0 | Skip N results for pagination |
source | string | — | auto_ingested, human, or source name (gmail, make:gmail) |
List Memories
GET
GET /v1/memory/list?brain_id=550e8400-...&limit=50&offset=0&type=decision
Authorization: Bearer cm_YOUR_API_KEY
List memories by recency (most recent first) without requiring a semantic search query. Use this for browsing, exporting, or admin operations where you need to see all memories rather than search for specific content.
/v1/memory/list returns memories sorted by created_at DESC — no embedding required. /v1/memory/search returns memories ranked by semantic similarity + BM25 — requires a meaningful query. Use list when you want "show me everything" and search when you want "find something specific."| Param | Type | Default | Description |
|---|---|---|---|
brain_id | uuid | Auth brain | Target brain (platform keys must specify this) |
limit | int | 50 | Max results (cap: 100) |
offset | int | 0 | Pagination offset |
type | string | — | Filter by type: decision, constraint, fact, preference, observation |
Response includes total count for pagination awareness.
Bulk Import
POST
POST /v1/memory/add/bulk
Content-Type: application/json
Authorization: Bearer cm_YOUR_API_KEY
{
"target_brain_id": "11c6dc92-4d25-...",
"memories": [
{ "content": "Email templates use Handlebars syntax with {{variable}} placeholders", "type": "fact" },
{ "content": "Never fabricate claims about a prospect's organization", "type": "constraint", "salience": "critical" },
{ "content": "CauseIQ revenue field maps to annual_revenue in our scoring model", "type": "fact" }
]
}
Response:
{ "stored": 3, "memories": [{ "id": "...", "type": "fact" }, ...] }
Archive a Memory
POST
POST /v1/memory/archive/a1b2c3d4-e5f6-7890-abcd-ef1234567890
Content-Type: application/json
Authorization: Bearer cm_YOUR_API_KEY
{
"reason": "Decision reversed — switching to UCB algorithm after testing",
"superseded_by": "f5e6d7c8-..."
}
Link Memories
POST
POST /v1/memory/link
Content-Type: application/json
Authorization: Bearer cm_YOUR_API_KEY
{
"source_memory_id": "a1b2c3d4-...",
"target_memory_id": "f5e6d7c8-...",
"relationship_type": "depends_on"
}
Relationship types: depends_on, conflicts_with, supersedes, relates_to, decided_because_of, refines
relates_to on store, supersedes on archive. Only use this for depends_on and conflicts_with which require reasoning.Load Brain
POST
POST /v1/brain/load
Authorization: Bearer cm_YOUR_API_KEY
Response includes: brain info, session_id, recent sessions, critical memories, active context, triggered memories, stats, constellations (topic clusters), accessible_brains (with IDs, descriptions, routing_rules), inherited policies from parent brains, and documentation URLs.
Brain Overview
GET
# All brains — big picture
GET /v1/brain/overview?days=7
# Single brain — island mode
GET /v1/brain/overview?days=7&brain_id=a8cb5930-...
# Brain + all children — family mode
GET /v1/brain/overview?days=7&brain_id=a8cb5930-...&include_children=true
# Multiple specific brains
GET /v1/brain/overview?days=7&brain_id=a8cb5930-...,dbaac3d8-...
Returns: period, memories_created, decisions, constraints, constellations, ingestion stats, priority ingredients, conflicts.
Log Session
POST
POST /v1/session/log/e3682a47-f644-...
Content-Type: application/json
Authorization: Bearer cm_YOUR_API_KEY
{
"summary": "Built the auto-ingestion pipeline. Deployed /v1/ingest with two-signal routing. Tested multi-brain auto-routing (3/3 correct). Still need to wire Make.com Gmail trigger."
}
List Brains
GET
# API key — discovery endpoint (returns brain hierarchy)
GET /v1/brains
Authorization: Bearer cm_YOUR_API_KEY
# OAuth — full details with member counts
GET /v1/brains
Authorization: Bearer eyJhbGciOiJS...
Response:
{
"brains": [
{
"id": "16e36a79-...",
"name": "Company Brain",
"description": "Company-wide strategy and cross-cutting decisions",
"routing_rules": "Product-specific → product brains. Engineering → Engineering Brain.",
"parent_brain_id": null,
"access_level": "admin",
"memory_count": 142,
"member_count": 5
},
{
"id": "b8f0912d-...",
"name": "AI Org Brain",
"description": "Shared AI infrastructure and cross-product strategy",
"parent_brain_id": "16e36a79-..."
}
],
"count": 8
}
Create Brain
POST
(OAuth only, Pro tier+)POST /v1/brains
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJS...
{
"name": "Deal Support",
"description": "Call scripts, objection handling, competitive intel, deal qualification frameworks",
"parent_brain_id": "b8f0912d-..."
}
Response:
{
"brain_id": "890a1c97-...",
"name": "Deal Support Brain",
"your_role": "admin",
"message": "Brain created. Invite members with POST /v1/brains/890a1c97-.../members"
}
Update Brain
PATCH
(OAuth only, admin)PATCH /v1/brains/890a1c97-...
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJS...
{
"description": "Updated description...",
"routing_rules": "Call scripts stay here. Competitive intel → Market Intel Brain."
}
Members
Invite a Member
POST
POST /v1/brains/890a1c97-.../members
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJS...
{ "email": "sarah@company.com", "access_level": "editor" }
Response (existing user):
{ "status": "granted", "email": "sarah@company.com", "access_level": "editor" }
Response (new user):
{ "status": "invited", "email": "sarah@company.com", "message": "Will auto-join on first login" }
List Members
GET
GET /v1/brains/890a1c97-.../members
Authorization: Bearer eyJhbGciOiJS...
Remove Member
DELETE
DELETE /v1/brains/890a1c97-.../members/user-uuid-here
Authorization: Bearer eyJhbGciOiJS...
Change Access Level
PATCH
PATCH /v1/brains/890a1c97-.../members/user-uuid-here
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJS...
{ "access_level": "admin" }
Access levels: viewer (read-only), editor (read + write), admin (manage members + configure brain).
Analytics
Brain Analytics (admin)
GET
GET /v1/brains/890a1c97-.../analytics
Authorization: Bearer eyJhbGciOiJS...
Returns: total memories, type breakdown (decisions/facts/constraints), total recalls, high-impact memories, top contributors, daily activity.
Org Analytics (Business tier, admin)
GET
GET /v1/brains/16e36a79-.../analytics/org
Authorization: Bearer eyJhbGciOiJS...
Returns: child brains, total memories across hierarchy, unique contributors, per-brain breakdown, cross-brain conflicts.
SCIM Provisioning
POST
(OAuth only, admin)POST /v1/provision
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJS...
{
"users": [
{ "email": "sarah@company.com", "name": "Sarah", "brain_id": "890a1c97-...", "access_level": "editor" },
{ "email": "mike@company.com", "name": "Mike", "brain_id": "890a1c97-...", "access_level": "viewer" }
]
}
Up to 100 users per request. Creates user + personal brain if new. Grants access to target brain.
Team Brains: End-to-End Workflow
Complete example: create a brain hierarchy, invite members, store with routing, search across brains.
Step 1: Create the hierarchy
# Create parent brain (OAuth required)
curl -X POST https://mcp.zombie.codes/v1/brains \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{
"name": "Sales Division",
"description": "Cross-team sales strategy, pipeline metrics, territory planning"
}'
# Response: { "brain_id": "aaa-111-...", "name": "Sales Division Brain" }
# Create child brains under it
curl -X POST https://mcp.zombie.codes/v1/brains \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{
"name": "Outbound",
"description": "Cold outbound email pipeline, targeting, templates, A/B testing",
"parent_brain_id": "aaa-111-..."
}'
# Response: { "brain_id": "bbb-222-...", "name": "Outbound Brain" }
curl -X POST https://mcp.zombie.codes/v1/brains \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{
"name": "Deal Support",
"description": "Call scripts, objection handling, competitive intel, deal qualification",
"parent_brain_id": "aaa-111-..."
}'
# Response: { "brain_id": "ccc-333-...", "name": "Deal Support Brain" }
Step 2: Add routing rules
curl -X PATCH https://mcp.zombie.codes/v1/brains/aaa-111-... \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{
"routing_rules": "Email pipeline and targeting decisions → Outbound Brain. Call scripts and objection handling → Deal Support Brain. Only cross-team sales strategy stays here."
}'
Step 3: Invite team members
# Invite to parent — they inherit access to children if cascades=true
curl -X POST https://mcp.zombie.codes/v1/brains/aaa-111-.../members \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{ "email": "sarah@company.com", "access_level": "editor" }'
# Invite to specific child only
curl -X POST https://mcp.zombie.codes/v1/brains/bbb-222-.../members \
-H "Authorization: Bearer eyJhbGciOiJS..." \
-H "Content-Type: application/json" \
-d '{ "email": "mike@company.com", "access_level": "viewer" }'
Step 4: Store memories with routing
# Store to a specific brain using target_brain_id (API key)
curl -X POST https://mcp.zombie.codes/v1/memory/add \
-H "Authorization: Bearer cm_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "We chose Thompson Sampling over UCB for play card selection because it naturally balances exploration vs exploitation without manual tuning",
"type": "decision",
"salience": "elevated",
"target_brain_id": "bbb-222-..."
}'
# The routing_check in the response confirms it landed correctly:
# { "routing_check": { "stored_in": { "brain": "Outbound Brain", "confidence": 0.48 },
# "alternatives": [{ "brain": "Sales Division Brain", "confidence": 0.31 }] } }
Step 5: Search across all brains
# Search finds memories from ALL accessible brains automatically
curl "https://mcp.zombie.codes/v1/memory/search?q=play+card+selection" \
-H "Authorization: Bearer cm_YOUR_API_KEY"
# Results include brain_name for provenance:
# { "memories": [
# { "content": "Thompson Sampling for play cards...", "brain_name": "Outbound Brain" },
# { "content": "Sales strategy requires data-driven...", "brain_name": "Sales Division Brain" }
# ] }
Step 6: Get the big picture
# Family mode — Sales Division + all children as one view
curl "https://mcp.zombie.codes/v1/brain/overview?brain_id=aaa-111-...&include_children=true&days=7" \
-H "Authorization: Bearer cm_YOUR_API_KEY"
# Returns aggregated: memories_created, decisions, constraints, constellations
# across Sales Division + Outbound + Deal Support as one unified view
Auto-Ingest
POST
POST /v1/ingest
Content-Type: application/json
Authorization: Bearer zwh_YOUR_WEBHOOK_TOKEN
{
"content": "Fathom transcript: Q3 planning meeting. Decided to prioritize mobile over desktop...",
"source_channel": "fathom",
"source_user": "ceo@company.com",
"source_id": "meeting_q3_planning"
}
Response:
{
"id": "d590a361-...",
"brain_id": "b8f0912d-...",
"brain_name": "AI Org Brain",
"source": "webhook",
"source_channel": "fathom",
"source_user": "ceo@company.com",
"source_label": "webhook:fathom:ceo@company.com",
"auto_routed": true,
"routing": { "method": "confidence", "confidence": 0.45 },
"dedup": { "action": "created" }
}
| Field | Type | Required | Description |
|---|---|---|---|
content | string | yes | The content to ingest |
source_channel | string | no | Sub-source: gmail, fathom, #engineering |
source_user | string | no | Author identity: user@company.com |
source_id | string | no | Dedup key (thread_id). Same source_id = supersede previous |
target_brain_id | uuid | no | Skip auto-routing — place directly in this brain |
Connectors
Connectors control which brains an ingestion source can write to, what attribution model to use, and generate the webhook token for auth.
Create a Connector (via MCP)
// In a conversation with Zombie Brains connected:
manage(action: "create_connector",
name: "Company Gmail",
source: "gmail",
brain_id: "16e36a79-...",
connector_type: "personal",
connector_config: '{"allowed_brain_ids": ["16e36a79-...", "b8f0912d-...", "11c6dc92-..."], "default_brain_id": "16e36a79-..."}'
)
// Response:
{
"connector_id": "50c7c464-...",
"name": "Company Gmail",
"webhook_token": "zwh_b45379ea20214497b8b200da...",
"allowed_brain_ids": ["16e36a79-...", "b8f0912d-...", "11c6dc92-..."],
"auth_header": "Authorization: Bearer zwh_b45379ea20214497b8b200da..."
}
Connector Types
| Type | stored_by | Use case | Retrieval behavior |
|---|---|---|---|
personal | Connector owner | Gmail, Outlook — one person's content | User-biased retrieval boosts for owner |
shared | null | Fathom, Slack — team content | Equal relevance for everyone |
Channel Mappings (multi-channel sources)
// For Slack/Discord — map channels to specific brains
manage(action: "configure_connector",
connector_id: "50c7c464-...",
connector_config: '{"channel_mappings": {"#engineering": "a08510f2-...", "#sales": "890a1c97-...", "#random": null}}'
)
// #engineering messages → Engineering Brain
// #sales messages → Deal Support Brain
// #random → excluded (null = drop)
List and Manage Connectors
// See all your connectors (MCP)
manage(action: "list_connectors")
// Update a connector's config (MCP)
manage(action: "configure_connector",
connector_id: "50c7c464-...",
connector_config: '{"allowed_brain_ids": ["16e36a79-...", "NEW_BRAIN_ID"]}'
)
Connector REST API
# List connectors
GET /v1/connectors
Authorization: Bearer cm_YOUR_API_KEY
# Create a connector
POST /v1/connectors
{ "name": "Company Gmail", "source": "gmail", "brain_id": "16e36a79-...", "connector_type": "personal" }
# Update a connector
PUT /v1/connectors/50c7c464-...
{ "name": "Updated Name", "config": {"allowed_brain_ids": ["..."]}, "brain_id": "new-brain-id" }
# Delete a connector (cascades: removes all routes)
DELETE /v1/connectors/50c7c464-...
# List routes for a connector
GET /v1/connectors/50c7c464-.../routes
# Add a route
POST /v1/connectors/50c7c464-.../routes
{ "signal_type": "sender", "signal_value": "ceo@company.com", "target_brain_id": "exec-brain-..." }
# Delete a route
DELETE /v1/connectors/50c7c464-.../routes/ROUTE_ID
Webhook Translator
Inbound external events (Fathom recordings, GitHub events, Stripe webhooks, anything that POSTs JSON) translated into ZB's canonical document shape and ingested. The translator is user-authored JS or Python that runs in a sandboxed runtime; the platform handles signature verification, lifecycle, fixtures, alerts, and routing.
POST /v1/webhook/:slug. ZB verifies the signature, runs your translator, ingests the resulting documents, and (optionally) fans them out to customer-registered destinations via output routing. Different translators per source, same canonical schema downstream.Create a Connector
Lifecycle: create (draft) → update_config (write the translator) → add_test_fixture + test_translator → activate (preflight) → live.
manage({
action: "create_webhook_connector",
name: "Fathom transcripts",
brain_id: "<brain-uuid>",
signature_scheme: "hmac_sha256", // or "standard_webhooks" or "none"
webhook_secret: "<random-secret>",
translator_runtime: "v8" // or "python"
})
Returns { connector_id, webhook_slug, ingest_url, status: 'draft' }. Give the customer's upstream service the ingest_url + webhook_secret; they configure their webhook receiver to POST signed events.
Write the Translator
The translator receives args = { body, raw_body, headers, query } and must return either:
// Translate to ZB's canonical schema
{
documents: [
{
content: "string (required)",
source_id: "string (recommended for dedup)",
source_channel: "fathom",
source_user: "robert@omnially.com",
metadata: { /* arbitrary JSON */ },
target_brain_id: "uuid (optional override)",
type: "linkedin_profile", // optional discriminator for output routing filters
tags: ["enrichment", "crm-sync"] // optional routing tags
}
]
}
// OR drop the event silently
{ drop: true, drop_reason: "not a recording-completed event" }
Set the translator code via update_webhook_config({webhook_connector_id, translator_code}). The same shape applies to any future translator-based source.
Fixtures & Dry-Run
Save real captured payloads as fixtures and dry-run the translator offline (no upstream call needed):
manage({
action: "add_webhook_test_fixture",
webhook_connector_id: "...",
fixture_name: "fathom-recording-completed",
fixture_raw_body: "<the actual JSON the upstream sends>",
fixture_expected_outcome: "ingest" // or "drop"
})
manage({
action: "test_webhook_translator",
fixture_id: "..."
})
// → { passed, summary, translator_output, first_document_preview }
Fixtures persist last_test_passed_at. Editing the translator code resets freshness — see list_webhook_test_fixtures for which fixtures have a fresh pass after the latest config change.
Activate
Hard preflight: translator set, secret consistent with the chosen signature scheme, default brain set, AND at least one fixture has a fresh pass since the last config edit.
manage({ action: "activate_webhook_connector", webhook_connector_id: "..." })
The response carries an next_step_for_ai field that explicitly instructs the AI to verify a real upstream-triggered event reaches the brain end-to-end before declaring the integration done — no false-shipping.
Pause / delete: pause_webhook_connector rejects incoming events with 503 until reactivation; delete_webhook_connector soft-deletes.
Events Log
Every inbound POST writes one row to webhook_events regardless of outcome. Use it for sample collection in draft mode and for debugging in production.
manage({
action: "list_webhook_events",
webhook_connector_id: "...",
webhook_event_filter: "errors", // all | success | errors | draft | signature_failed | dropped
limit: 25
})
manage({ action: "get_webhook_event", webhook_event_id: "..." })
// → full raw_body, headers, query, translator_output, ingested_doc_ids
manage({ action: "get_webhook_samples", webhook_connector_id: "..." })
// → distinct payload shapes (deduped by content hash) — ideal for translator authoring
Failure alerts: translator errors and full ingest failures email the connector owner via Resend, rate-limited to one email per connector per 30 minutes. Signature failures don't alert (too noisy from random scanners). Aggregate count surfaced in the next email.
Output Routing
Generic fan-out from any "source" (today: webhook connector) to any "destination" (customer-owned webhook URL, today; brain alias, polling-feed in future). Same canonical document shape; different sinks consume it.
Register a Destination
manage({
action: "register_output_destination",
name: "HubSpot prospect sync",
output_destination_url: "https://customer.com/webhook-receiver",
output_destination_kind: "webhook"
})
// → { output_destination_id, signing_secret, signing_scheme: "standard_webhooks" }
The signing secret is returned once on register, encrypted at rest after that. The receiver verifies via v1,base64(HMAC-SHA256(webhook-id + "." + webhook-timestamp + "." + raw_body, signing_secret)) — same scheme ZB accepts on the inbound side, so customers can reuse one verifier library on both directions.
Attach a Route
manage({
action: "attach_output_route",
output_source_kind: "webhook_connector",
output_source_id: "<source uuid>",
output_destination_id: "<destination uuid>",
output_payload_format: "translated_docs" // or "raw_payload"
})
Multiple routes can attach to the same source — typical setup is "land in brain (always)" + "POST to HubSpot (filtered)" + "POST to customer warehouse (raw)" all from one source.
Type / Tag Filters
For sources whose translator emits heterogeneous docs (e.g. a single source that produces both type: 'contact' and type: 'company' docs), routes can filter per-document so different shapes go to different destinations:
manage({
action: "attach_output_route",
output_source_kind: "webhook_connector",
output_source_id: "...",
output_destination_id: "<hubspot-contacts-receiver>",
output_type_filter: "contact", // exact match on doc.type
output_tag_filter: ["hubspot-sync"] // ALL tags must be present
})
Routes whose filter matches zero docs are silently skipped — no empty payloads at the destination, no log noise. Each delivery's filter outcome is reflected in the list_output_deliveries count.
Delivery Log
manage({
action: "list_output_deliveries",
output_destination_id: "...", // optional filter
limit: 25
})
// → { deliveries: [{ http_status, delivered, error, payload_bytes, duration_ms, ... }] }
Phase 1 = best-effort delivery, log only, no retries. Phase 2 will add exponential-backoff retry, dead-letter queue, and a replay_output_delivery action for manual recovery.
Dedup & Source Hierarchy
Sources use a three-level hierarchy: platform:channel:identity
make:gmail:user@company.com — Make.com pulling from Gmail
zapier:fathom:user@company.com — Zapier pulling from Fathom
webhook:crm:deal_updates — Custom webhook from CRM
slack:#engineering:@sarah — Slack message from Sarah
Dedup: Same source_id in the same brain = supersede. Email threads: one memory per thread_id, updated on each new reply. The source filter in search supports hierarchy: source=make:gmail matches platform + channel.
Make.com
Create a reusable Make Tool, then wire any trigger to it. Here is the complete, copy-paste-ready tool creation call:
Complete Make Tool Configuration
// Use Make:tools_create with these exact values
{
"name": "Zombie Brains: Ingest Memory",
"description": "Sends content to Zombie Brains for auto-routing.",
"teamId": YOUR_TEAM_ID,
"inputs": [
{ "name": "content", "type": "text", "required": true, "description": "The content to ingest" },
{ "name": "source_channel", "type": "text", "required": false, "description": "Sub-source: gmail, fathom, etc." },
{ "name": "source_user", "type": "text", "required": false, "description": "Author identity" },
{ "name": "source_id", "type": "text", "required": false, "description": "Dedup key (thread_id)" }
],
"module": {
"module": "http:ActionSendData",
"version": 3,
"parameters": {
"handleErrors": true
},
"mapper": {
"url": "https://mcp.zombie.codes/v1/ingest",
"method": "post",
"bodyType": "raw",
"contentType": "application/json",
"data": "{\\"content\\":\\"{{var.input.content}}\\",\\"source_channel\\":\\"{{var.input.source_channel}}\\",\\"source_user\\":\\"{{var.input.source_user}}\\",\\"source_id\\":\\"{{var.input.source_id}}\\"}",
"headers": [
{ "name": "Authorization", "value": "Bearer zwh_YOUR_WEBHOOK_TOKEN" },
{ "name": "Content-Type", "value": "application/json" }
],
"parseResponse": true,
"serializeUrl": false,
"followRedirect": true,
"followAllRedirects": false,
"shareCookies": false,
"rejectUnauthorized": true,
"useQuerystring": false,
"gzip": true,
"useMtls": false,
"timeout": 30,
"qs": []
},
"metadata": { "designer": { "x": 0, "y": 0 } }
}
}
- Module is
http:ActionSendDatav3 — NOThttp:MakeRequestv4 (different module entirely) - Body field is
data— NOTrawBodyContent(that’s MakeRequest v4) followAllRedirectsis mandatory whenfollowRedirectis true — omitting it causes BundleValidationErrorhandleErrors: truegoes inparameters(static config), NOT inmapper(dynamic config)- Every mapper field shown above is required — omitting any causes validation failure
Wiring a Gmail Trigger
Create a Make.com scenario: Gmail “Watch Emails” trigger → your Zombie Brains tool:
// Field mapping in Make.com scenario
content → {{1.textPlain}} // email body text
source_channel → "gmail" // hardcoded string
source_user → {{1.from.address}} // sender email
source_id → {{1.threadId}} // enables dedup on replies
Validate Before Creating
// Always validate first to catch errors
Make:validate_module_configuration({
"appName": "http",
"appVersion": 3,
"moduleName": "ActionSendData",
"organizationId": YOUR_ORG_ID,
"teamId": YOUR_TEAM_ID,
"parameters": { "handleErrors": true },
"mapper": { /* same mapper object as above */ }
})
// Success: { "valid": true, "errors": [], "warnings": [] }
// Failure: { "valid": false, "errors": [{ "path": "followAllRedirects", "message": "Field is mandatory." }] }
Zapier
Everything is configured via the Zapier web dashboard — no code needed.
Path 1: MCP Tool (AI can call it from chat)
// Setup at mcp.zapier.com:
1. Click + Add tool
2. Search "Webhooks by Zapier" → select POST
3. Configure:
URL: https://mcp.zombie.codes/v1/ingest
Payload Type: JSON
Headers:
Authorization Bearer zwh_YOUR_WEBHOOK_TOKEN
Content-Type application/json
Data fields:
content [Set to "Have AI fill in"]
source_channel [Set to "Have AI fill in"]
source_user [Set to "Have AI fill in"]
source_id [Set to "Have AI fill in"]
4. Save → tool appears in Claude/ChatGPT
Path 2: Traditional Zap (runs automatically)
// Create a Zap at zapier.com:
1. Trigger: Gmail "New Email" (or Slack, Fathom, etc.)
2. Action: Webhooks by Zapier → POST
URL: https://mcp.zombie.codes/v1/ingest
Payload Type: JSON
Headers:
Authorization Bearer zwh_YOUR_WEBHOOK_TOKEN
Data:
content {{body_plain}} // from Gmail trigger
source_channel gmail // hardcoded
source_user {{from_email}} // from Gmail trigger
source_id {{thread_id}} // from Gmail trigger
3. Turn on → new emails auto-ingest into the best brain
Brain Hierarchy
Core principle: search crosses brain boundaries. A bot connected to a child brain automatically inherits knowledge from all parent brains. Shared knowledge goes UP (parents), specific knowledge goes DOWN (children).
🏢 Company Brain (universal knowledge — every bot finds this)
├── 🤖 AI Org Brain (shared AI patterns — all AI bots find this)
│ ├── 📧 Outbound Bot Brain (email pipeline — only outbound bot)
│ ├── 💬 Assistant Brain (conversational AI — only assistant bot)
│ └── 🔍 Deal Support Brain (call scripts — only deal support bot)
└── ⚙️ Engineering Brain (shared infra — all engineering bots)
├── 📺 Product A Brain
└── 📱 Product B Brain
Create brains for ownership boundaries, not topics. A brain = a team, product, or organizational unit with its own decision-making authority.
KB Migration
Migrating a central knowledge base into a brain hierarchy:
- Discover brains:
GET /v1/brainsto see IDs and descriptions - Map categories: Your source system tags each item with the target brain ID
- Bulk import:
POST /v1/memory/add/bulkwithtarget_brain_id, 100 per request - Unsure items → parent brain: The AI re-routes organically during sessions
- After import: Auto-routing works for ongoing ingestion (brains now have content to compare against)
target_brain_id for initial imports.AI Discovery
How any AI finds its way around:
- MCP:
load_brainreturnsaccessible_brains[]with IDs, names, descriptions, routing_rules +documentation{}with URLs - REST:
GET /v1/brainsreturns the full hierarchy - Skill file:
GET /skillreturns the comprehensive behavioral guide + full API reference
Tier Comparison
| Limit | Free | Pro ($19/mo) | Business ($39/seat, 5 min) | Enterprise |
|---|---|---|---|---|
| Brains | 1 | 25 | 10/seat | Custom |
| Memories | 5,000 | 25,000 | 35,000/seat | Custom |
| Recalls/month | 1,000 | 10,000 | 15,000/seat | Custom |
| API calls/month | 5,000 | 50,000 | 75,000/seat | Custom |
| Storage | 100 MB | 1 GB | 1.5 GB/seat | Custom |
| Members | 1 | 1 (solo) | 5 min, no cap | Custom |
| Skills | 50 | 200 | 200/seat | Custom |
| Agents | 3 | 25 | 25/seat | Custom |
| Serverless tools | 10 | 75 | 75/seat | Custom |
| Connectors | 5 | 25 | 25/seat | Custom |
| Roles | Unlimited | Unlimited | Unlimited | Unlimited |
| Capability | Free | Pro | Business | Enterprise |
|---|---|---|---|---|
| All core MCP tools | ✓ | ✓ | ✓ | ✓ |
| MCP relay | ✓ | ✓ | ✓ | ✓ |
| Brain hierarchy | — | 2 levels | Unlimited depth | Unlimited depth |
| Admin dashboard | ✓ | ✓ | ✓ | ✓ |
| Brain analytics | — | Per-brain | Per-brain + org-wide | Per-brain + org-wide |
| Fine-tuning export | — | ✓ | ✓ | ✓ |
| SCIM provisioning | — | — | ✓ | ✓ |
| SSO (SAML/OIDC) | — | — | — | ✓ |
| SOC 2 attestation | — | — | — | ✓ |
| Dedicated support | — | — | — | ✓ |
| SLA | — | — | — | 99.9% |
Business limits stack per seat. Each additional seat adds to the pool — brains, memories, recalls, storage, agents, tools, and connectors all multiply linearly. Member capacity has a floor of 5 (= seat minimum) with no cap — grow past 5 members by adding more seats. Enterprise limits are negotiated per deal.
Platform API
The Platform API enables third-party applications to use Zombie Brains as invisible infrastructure — your users never see or know about Zombie. This is the Stripe/Auth0/Twilio pattern: you embed the service, manage everything programmatically, and your users just experience the magic.
- Register your platform (one-time) → get a
zp_platform key - User signs up on your app → your backend calls
POST /v1/platform/brains→ creates their personal brain + gets a scopedcm_API key - User creates a project → your backend creates a child brain under their personal brain
- Upload documents →
POST /v1/documentswithX-Brain-Idheader targeting the project brain - AI assistant connects → MCP connection using the scoped
cm_key, automatically scoped to that brain
zp_) = admin ops on any brain you own. Scoped key (cm_) = operations on one brain. Platform keys require X-Brain-Id header for operational endpoints (memory, documents).Register Platform
POST
POST /v1/platform/register
Authorization: Bearer <oauth_token> ← requires OAuth, not platform key
Content-Type: application/json
{ "name": "My Writing Tool", "description": "AI-powered writing assistant" }
Response:
{
"platform_id": "550e8400-...",
"platform_key": "zp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4...",
"warning": "Store this key securely. It will not be shown again."
}
Create Brain
POST
POST /v1/platform/brains
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Content-Type: application/json
{
"name": "Author: Jane",
"description": "Writing preferences, style notes, pacing patterns",
"parent_brain_id": null ← optional, for hierarchy
}
Response:
{
"brain_id": "660f9411-...",
"api_key": "cm_x1y2z3w4v5u6t7s8r9...",
"name": "Author: Jane",
"warning": "Store the api_key securely. It will not be shown again."
}
Each brain gets an auto-generated scoped API key. Use parent_brain_id to create hierarchies (e.g., user brain → project brain). Child brains inherit Core Knowledge from parents.
List Brains
GET
GET /v1/platform/brains
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Returns all brains owned by your platform with memory counts, document counts, and creation dates.
Update Brain
PUT
PUT /v1/platform/brains/660f9411-...
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Content-Type: application/json
{ "name": "Author: Jane Smith", "description": "Updated description", "routing_rules": "Style preferences → personal brain. Series content → series brain." }
Update any combination of name, description, parent_brain_id, routing_rules.
Delete Brain
DELETE
DELETE /v1/platform/brains/660f9411-...
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Generate API Key
POST
POST /v1/platform/api-keys
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Content-Type: application/json
{ "brain_id": "660f9411-...", "label": "MCP connection for Jane" }
Response:
{
"api_key": "cm_new_key_here...",
"brain_id": "660f9411-...",
"brain_name": "Author: Jane",
"warning": "Store this key securely. It will not be shown again."
}
Generates additional scoped API keys for a brain. Each brain gets one key at creation; use this to create extras (e.g., for different MCP connections or rotation).
List / Revoke Keys
# List all keys (redacted previews)
GET /v1/platform/api-keys
Authorization: Bearer zp_YOUR_PLATFORM_KEY
# Revoke a specific key
DELETE /v1/platform/api-keys/KEY_ID
Authorization: Bearer zp_YOUR_PLATFORM_KEY
Agents
Agents are first-class cognitive entities — AI bots with personality, permissions, memory, and tools. Each agent gets its own MCP URL and can be configured entirely from the admin portal.
Agent Prompt
The agent_prompt field replaces Zombie's default system instructions when the agent connects. This is the agent's personality, training, and behavioral rules. Returned by load_brain as the agent_prompt field. Changes take effect on the next connection — no redeploy needed.
Tool Permissions
Each of the 9 MCP tools can be individually enabled/disabled per agent: load_brain, add_memory, search_memory, log_session, brain_overview, skills, manage, read_document, dashboard. Disabled tools don't appear in the agent's tool list.
Serverless Tools
Custom JavaScript tools that execute in sandboxed V8 isolates (workerd/Miniflare) on a separate service. Create once, assign to any agent.
env bindings → returns result → isolate disposed. The Tool Runner is stateless — no database, no credentials. Defense in depth.Writing a Tool
async function(args, env) {
// args = input arguments from the AI
// env = agent variables (API keys, config)
const resp = await fetch('https://api.example.com/search', {
headers: { 'Authorization': 'Bearer ' + env.API_KEY },
method: 'POST',
body: JSON.stringify({ query: args.query })
});
return await resp.json();
}
Tools have built-in fetch() for external API calls. Agent variables are injected as env. Input schemas (JSON Schema format) tell the AI what arguments the tool accepts.
Tools That Call MCPs
Serverless tools can compose any registered MCP relay's surface via env.mcp.<slug>.<tool_name>(args) — without ever holding upstream credentials. Declare the dependencies at create time:
manage({
action: "create_tool",
name: "linkedin_enrichment",
document_content: `async (args, env) => {
// env.mcp is auto-populated with the slugs in mcp_dependencies.
// ZB validates each call, resolves the credential per-caller (caller →
// permission set → owner), and dispatches via the upstream MCP.
const result = await env.mcp.apify_xyz['call-actor']({
actor: 'harvestapi/linkedin-profile-scraper',
input: { profiles: [args.linkedin_url] }
});
return { profile: result };
}`,
mcp_dependencies: ["apify_xyz"]
})
Each declared slug must resolve to an active MCP relay the caller owns at create time — otherwise the call rejects with a teaching error. At runtime, the tool's declared deps are intersected with the calling agent's accessible connectors; tools running for an agent without access to a declared MCP fail clearly rather than silently.
/v1/internal/mcp-dispatch; ZB resolves the actual credential per the scope chain and forwards via the existing MCP relay client. Token revoked on execution complete (success or fail) or on timeout.See the MCP Integrations section for the complete model — registering relays, the credential resolution chain, and the Apify example walkthrough.
Skills
Behavioral instructions stored in a library. Create once, assign to brains and/or agents. Skills shape HOW the AI works — coding conventions, communication style, domain expertise, process rules.
Skills live on brains (inherited by everyone on that brain) and can be explicitly assigned to specific agents. They're versioned and loaded during hydration via the skills MCP tool.
Variables
Encrypted key-value store for API keys, tokens, and configuration. Three-tier inheritance:
- Organization (Settings page) — shared by all brains and agents
- Brain — shared by all agents on that brain
- Agent — specific to one agent, highest priority
Each level overrides the one above. All values encrypted at rest (AES-256-GCM). Variables are injected into serverless tool execution as env bindings and returned by load_brain as agent_variables.
For credential resolution at MCP-call time (e.g. "use the calling user's HubSpot token, fall back to the role's, fall back to the org's"), see the credential chain.
Secure Credential Links
Credentials should never enter the chat with an AI. create_variable_link mints a single-use, 10-minute-TTL URL the user opens in their browser to set a variable directly — the value is encrypted (AES-256-GCM) the moment they submit and never round-trips through the model's context.
manage({
action: "create_variable_link",
var_name: "APIFY_TOKEN",
var_scope: "org" // or "brain" / "agent" / "permission_set"
})
// → { url: "https://mcp.zombie.codes/set-variable/<token>",
// expires_in: "10 minutes", single_use: true }
The AI hands the URL to the user; the user opens it, pastes their credential, submits. The variable is persisted at the requested scope.
awaiting_credential on the same variable name, submitting the link also finalizes those connectors inside the same browser request — credential is formatted into auth headers, encrypted onto the connector row, upstream tool manifest synced, status flipped to connected. Single AI call + single user click → live MCP integration.MCP Integrations
Zombie Brains is an MCP host on both directions. Inbound: agents connect to ZB's MCP server and call native tools (memory, brain management, etc.). Outbound: ZB connects to upstream MCP servers (Apify, HubSpot, GitHub, Linear, Notion, etc.) and relays their tool surface to agents — and to your serverless tools — through your permission layer.
env.mcp.<slug>.<tool>(args). Credentials walk a per-caller scope chain so different identities can call the same connector with different upstream creds (HubSpot read-only vs. read-write, etc.).Register an MCP Relay
One manage call. The credential never enters chat — pass a variable name and ZB returns an inline secure link the user submits in their browser.
manage({
action: "register_mcp_relay",
name: "Apify",
brain_id: "<brain-uuid>",
mcp_url: "https://mcp.apify.com",
mcp_auth_credential_var: "APIFY_TOKEN",
mcp_auth_credential_format: "bearer" // bearer | basic | header_value
})
Response (when the variable isn't set yet):
{
"connector_id": "...",
"slug": "apify_xyz",
"mcp_url": "https://mcp.apify.com",
"status": "awaiting_credential",
"pending_variable_link": "https://mcp.zombie.codes/set-variable/<token>",
"pending_variable_expires_in": "10 minutes (single-use)",
"next_step_for_ai": "Tell the user to open this link..."
}
The user opens the link, pastes their token, submits. The moment they submit:
- Variable is encrypted (AES-256-GCM) and stored at the requested scope.
- The waiting connector auto-finalizes inside that same browser request — credential formatted into headers, encrypted, stored on the connector row.
- Upstream tool manifest syncs (
tools/listagainst the upstream MCP). - Status flips
awaiting_credential → connected. The relay is live.
If the variable is already set at registration time, all of that happens server-side in the same MCP call — no link, no user step. next_step_for_ai tells the AI exactly what state it's in either way.
Other lifecycle actions:
sync_mcp_relay({connector_id})— re-fetch the upstream manifest. Useful if the upstream added or removed tools.delete_mcp_relay({connector_id})— soft-delete; cascades to dependent serverless tool dependencies on next call.
Connector Templates (One-Click Connect)
For common upstream MCPs ZB knows about, skip the URL/scope/provider hassle. Pass template: and the rest auto-fills.
manage({ action: "list_connector_templates" })
// → { templates: [
// { name: "google_gmail", display_name: "Gmail", category: "google_workspace",
// mcp_url: "https://gmailmcp.googleapis.com/mcp/v1",
// oauth_provider: "google",
// default_scopes: [".../gmail.readonly", ".../gmail.compose"],
// exposes: ["search_threads", "create_draft", "list_labels", ...],
// notes: ["Drafts only — no send. For autonomous send, see BYOO pattern."] },
// { name: "google_calendar", ... },
// { name: "google_drive", ... },
// { name: "google_chat", ... },
// { name: "fathom", ... },
// ] }
manage({
action: "register_mcp_relay",
template: "google_gmail",
name: "Gmail",
brain_id: "<brain-uuid>",
oauth_scope_target: "user:<user-uuid>" // tokens belong to this user
})
// → { connect_url: "...", template_applied: { name: "google_gmail", defaults: {...} }, ... }
Default scope sets stay on the non-restricted tier so the platform OAuth client never needs Google's CASA security assessment. Customers needing restricted scopes (Gmail send, Drive full-read, etc.) bring their own OAuth client via the BYOO pattern.
Credential Resolution Chain
When an agent or human triggers a tool call that uses an MCP relay, ZB resolves the credential by walking three scopes in order. First non-null match wins:
- Caller identity — the calling agent's
variables_encrypted, or the calling user's. - Permission set — the role assigned to the caller (one per identity). Useful for "Sales RW" vs. "Sales RO" patterns: same connector, different upstream credentials per role.
- Owner / org — the user who owns the agent (or the user themselves), as the org-wide fallback.
env.mcp in Serverless Tools
Serverless tools (V8 sandbox) can call any registered MCP relay by declaring it as a dependency:
manage({
action: "create_tool",
name: "linkedin_enrichment",
document_content: `async (args, env) => {
// env.mcp.<slug>.<upstream_tool_name>(args)
// Sandbox NEVER sees upstream credentials — ZB resolves them
// per-caller and dispatches via callback.
const profile = await env.mcp.apify_xyz['call-actor']({
actor: 'harvestapi/linkedin-profile-scraper',
input: { profiles: [args.linkedin_url] }
});
return { enriched: profile };
}`,
mcp_dependencies: ["apify_xyz"]
})
How it works under the hood:
- At create time, every slug in
mcp_dependenciesis validated against connectors the caller owns. Tools can't declare deps that wouldn't grant. - At execute time, ZB intersects declared deps with the calling agent's accessible connectors, mints a short-lived callback token, and persists an
agent_tool_executionsrow. - The tool runner injects an
env.mcpProxy. Property access (e.g.env.mcp.apify_xyz) returns a sub-Proxy whose method calls (['call-actor'](args)) issue a signed POST back to ZB at/v1/internal/mcp-dispatch. - ZB validates the token, re-checks caller access (defense-in-depth), resolves the credential per the scope chain above, and forwards via the existing MCP relay client. Result returns inline.
- On execution complete (success or failure), the token is revoked. Mid-flight expiry kills the token too — tool timeout is the upper bound on credential exposure.
Example: Apify
Apify's MCP server (https://mcp.apify.com) exposes 8 meta-tools — search-actors, fetch-actor-details, call-actor, get-actor-run, get-actor-output, apify--rag-web-browser, fetch-apify-docs, search-apify-docs — through which agents reach Apify's full 6,000+ actor catalog dynamically. No per-actor registration needed.
Full agent flow for "scrape this LinkedIn profile":
- Agent calls
env.mcp.apify_xyz['search-actors']({search: 'linkedin profile'})→ list of candidate actors. - Agent picks one (e.g. HarvestAPI's profile scraper), calls
env.mcp.apify_xyz['fetch-actor-details']({actor: 'harvestapi/linkedin-profile-scraper'})→ input schema. - Agent calls
env.mcp.apify_xyz['call-actor']({actor: '...', input: {...}})→ dataset. - Agent or wrapper tool stores the dataset as memory via
add_memory.
The HarvestAPI suite is a good starting point — they publish profile, profile-search, profile-search-by-name, profile-posts, company-employees, company-posts, job-search, and post-search actors on Apify Store. apify.com/harvestapi/linkedin-profile-scraper is the most-used.
MCP Relay (legacy connector type)
For UI / dashboard: the older "MCP Server" connector type at POST /v1/connectors still works. The newer register_mcp_relay manage action is the recommended path because it's MCP-native (no OAuth detour) and supports the inline-credential-link UX. Both write to the same connectors table; tools relayed from either path appear identically to agents.
Custom OAuth-Gated Tools (BYOO)
ZB ships baked-in connectors with non-restricted scopes — Gmail read + draft, Calendar read + write events, Drive (file scope), etc. Some workflows need scopes ZB's platform OAuth client deliberately doesn't carry: Gmail send, Drive full-read, etc. Those scopes are "restricted" by the upstream provider and require expensive security assessments to ship through one shared OAuth client. The BYOO ("Bring Your Own OAuth") pattern lets you ship them yourself: register your own OAuth client, get a refresh token, write a custom V8 tool that uses it.
Walkthrough — AI-Initiated (recommended)
The fast path. Single AI call mints a bundle link; one click + paste from the user; everything auto-saves. Same shape as variable links.
- Register your own OAuth client on the upstream provider. For Google: console.cloud.google.com → enable APIs you need → configure OAuth consent screen (External, Testing-mode for ≤100 users) → declare scopes → create OAuth client (Web application). For the redirect URI, use
https://mcp.zombie.codes/bundle-oauth-callback. Copyclient_id+client_secret. - Ask your AI to mint a bundle link:
manage({ action: "create_variable_bundle_link", bundle_variables: [ { name: "MY_GMAIL_CLIENT_ID", label: "Client ID" }, { name: "MY_GMAIL_CLIENT_SECRET", label: "Client Secret", description: "From your Google Cloud OAuth client" } ], bundle_oauth: { scopes: [ "https://www.googleapis.com/auth/gmail.send", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.modify" ], client_id_from: "MY_GMAIL_CLIENT_ID", client_secret_from: "MY_GMAIL_CLIENT_SECRET", derive: { "MY_GMAIL_REFRESH_TOKEN": "refresh_token" } }, bundle_provider_label: "OmniAlly Google", var_scope: "org" }) // → { url: "https://mcp.zombie.codes/set-variables/<token>", // variables_pasted: ["MY_GMAIL_CLIENT_ID", "MY_GMAIL_CLIENT_SECRET"], // variables_derived_from_oauth: ["MY_GMAIL_REFRESH_TOKEN"], // variables_will_be_saved: ["MY_GMAIL_CLIENT_ID", "MY_GMAIL_CLIENT_SECRET", "MY_GMAIL_REFRESH_TOKEN"], // expires_in: "30 minutes" } - User opens the link. Form shows the pasted variables (Client ID, Client Secret) + a banner explaining the OAuth dance comes next + the locked scope list. User pastes the two values and submits. ZB stages them, redirects to the provider's consent screen.
- User authorizes. Standard consent screen at the provider. After approving, ZB's callback auto-saves all three variables (
MY_GMAIL_CLIENT_ID,_CLIENT_SECRET,_REFRESH_TOKEN) at the requested scope, encrypted with AES-256-GCM. User sees a "Connected" page and closes the tab. - AI verifies + writes the tool.
manage(action: "list_variables", var_scope: "org")shows the new vars. Now write the custom V8 tool that readsenv.MY_GMAIL_CLIENT_IDetc. — see the refresh helper + worked example below.
Walkthrough — Plain Multi-Variable (no OAuth)
The same primitive works for non-OAuth multi-variable cases — Stripe pk + sk, Twilio account_sid + auth_token + phone_number, HubSpot Private App + Portal ID, etc. Just omit bundle_oauth.
manage({
action: "create_variable_bundle_link",
bundle_variables: [
{ name: "STRIPE_PUBLISHABLE_KEY", label: "Publishable Key", input_type: "text" },
{ name: "STRIPE_SECRET_KEY", label: "Secret Key",
description: "Lives in your Stripe dashboard → Developers → API keys" }
],
var_scope: "org"
})
// → { url: ..., variables_will_be_saved: ["STRIPE_PUBLISHABLE_KEY", "STRIPE_SECRET_KEY"] }
Form shows both fields, user pastes both, submits, both save atomically. Auto-finalizes any waiting MCP relays / webhook connectors that match either variable name (same hook as the single-variable variable links).
Walkthrough — Manual (Playground)
Power-user surface for cases where there's no AI in the loop (developer setup, exploratory testing, etc.). Same backend code as the AI-initiated path; just a different front door.
- Register your OAuth client (same as above), but set the redirect URI to
https://mcp.zombie.codes/oauth-playground/callback. - Open mcp.zombie.codes/oauth-playground. Pick your provider, fill in label + client credentials, tick the scopes you need. Submit. Walk through the provider consent. Land on the result page with refresh token + access token revealed via Show toggle.
- Back in your ZB MCP session, ask the AI to mint a bundle link with the three variable specs. Open the link, paste the matching values from the playground result page, submit.
- Write the custom V8 tool (next section).
AI prefill of the playground form
When the AI sends a customer to the playground, it should prefill what it knows. The customer only brings client_id + client_secret — the rest (provider, label, scopes) is in the chat context. Pass query params on the playground URL:
https://mcp.zombie.codes/oauth-playground
?provider=google # one of: google, hubspot (built-ins with auth_url + token_url)
&label=OmniAlly+Gmail+send # what the connection is FOR — anchor on use, not brand
&scopes=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly,https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.send
When provider matches a built-in catalog entry, the page auto-selects the dropdown, fills the authorize/token endpoints, renders the scope checkboxes from that catalog, and pre-checks the IDs in the scopes param. If provider is unknown or omitted, scope IDs land in the textarea for manual review. A "Pre-filled by your AI" banner makes it obvious to the customer what was suggested and what they should still review/adjust before submitting.
Refresh Helper Snippet
Drop this at the top of any custom tool that calls a Google API. Reads three variables (suffix-named per namespace) and returns a fresh access token. No state, no persistence — refreshes per call.
// Reusable refresh helper. NAMESPACE convention: suffix _CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN.
async function getGoogleAccessToken(env, namespace) {
const r = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env[`${namespace}_CLIENT_ID`],
client_secret: env[`${namespace}_CLIENT_SECRET`],
refresh_token: env[`${namespace}_REFRESH_TOKEN`],
grant_type: 'refresh_token',
}),
});
if (!r.ok) throw new Error('token refresh failed: ' + (await r.text()).slice(0, 300));
const { access_token } = await r.json();
return access_token;
}
The same shape works for any OAuth provider — swap the URL and you have a Slack / GitHub / Notion / etc. refresh helper.
env.helpers — utility belt
The V8 sandbox injects an env.helpers namespace alongside env.mcp with a small set of pure helpers that every BYOO tool author was about to re-derive. Each one is stateless, has no extra capabilities, and works in any tool — no declaration needed.
| Helper | Use case |
|---|---|
env.helpers.rfc2047(s) | Encode a header value (Subject, To/Cc display names, X-* headers). Pure ASCII passes through verbatim (zero overhead); non-ASCII gets =?utf-8?B?<base64>?= wrapping. See the RFC 2047 callout for why this matters. |
env.helpers.base64utf8(s) | UTF-8-safe base64 encode. Replaces the btoa(unescape(encodeURIComponent(s))) dance — works for arbitrary Unicode strings, unlike bare btoa(). |
env.helpers.base64url(s) | base64url (RFC 4648 §5) — URL-safe alphabet, no padding. Required by Gmail API raw field, JWT, OAuth PKCE verifier. |
await env.helpers.hmacSha256Hex(message, key) | HMAC-SHA256 hex digest. Webhook signatures, OAuth 1.0, Slack request verification. |
env.helpers.escapeHtml(s) | Five-char XML escape for safe text-in-HTML interpolation. Use when rendering HTML email bodies, status pages, etc. |
None of these add capabilities your tool doesn't already have — they're shorthand for boilerplate that would otherwise live in every tool's source. The implementation lives in zombie-tool-runner/index.js; PRs welcome to extend.
Worked Example — Gmail Send
Restricted scope, requires BYOO. ~30 lines using env.helpers:
manage({
action: "create_tool",
name: "gmail_send",
description: "Send an email via Gmail. Reads OAuth credentials from MY_GMAIL_* variables.",
document_content: `async (args, env) => {
// 1. Refresh access token
const tokenResp = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: env.MY_GMAIL_CLIENT_ID,
client_secret: env.MY_GMAIL_CLIENT_SECRET,
refresh_token: env.MY_GMAIL_REFRESH_TOKEN,
grant_type: 'refresh_token',
}),
});
if (!tokenResp.ok) throw new Error('refresh failed: ' + (await tokenResp.text()).slice(0, 200));
const { access_token } = await tokenResp.json();
// 2. Build RFC 5322 message. env.helpers.rfc2047 wraps non-ASCII headers
// in =?utf-8?B??= (ASCII passthrough — see byoo-rfc-2047 below).
const headers = [
\`To: \${args.to}\`,
args.cc ? \`Cc: \${args.cc}\` : null,
\`Subject: \${env.helpers.rfc2047(args.subject)}\`,
'Content-Type: text/plain; charset=UTF-8',
'MIME-Version: 1.0',
].filter(Boolean).join('\\\\r\\\\n');
const raw = env.helpers.base64url(\`\${headers}\\\\r\\\\n\\\\r\\\\n\${args.body}\`);
// 3. Send
const send = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: { Authorization: \`Bearer \${access_token}\`, 'Content-Type': 'application/json' },
body: JSON.stringify({ raw }),
});
const result = await send.json();
if (!send.ok) throw new Error('send failed: ' + JSON.stringify(result).slice(0, 300));
return { sent: true, message_id: result.id, thread_id: result.threadId };
}`,
tool_input_schema: {
type: "object",
properties: {
to: { type: "string", description: "Recipient email" },
cc: { type: "string", description: "CC recipients (comma-separated)" },
subject: { type: "string", description: "Subject line" },
body: { type: "string", description: "Plain-text body" }
},
required: ["to", "subject", "body"]
}
})
Header encoding (RFC 2047) — read this if your tool sends email
Subject, To display names, etc.) must be ASCII. If you put non-ASCII characters (em-dashes, accents, emoji, smart quotes, anything > U+007F) directly into a header, spec-strict mail clients render garbled output (UTF-8 bytes interpreted as Latin-1 → double-encoded mojibake like â€Â" instead of —). Gmail itself often renders raw UTF-8 OK; Apple Mail, Outlook, and many filtering proxies do not.The fix is RFC 2047 encoded-words: wrap non-ASCII strings in =?utf-8?B?<base64>?=. Use env.helpers.rfc2047(s) — it gates on the presence of any non-ASCII byte, pure ASCII passes through verbatim (zero overhead), non-ASCII gets encoded. Don't inline the helper; use the canonical version so the bug stays fixed in one place.
Apply it to any header that takes user/AI-generated content: Subject, the display-name part of To/From/Cc/Reply-To, X-* custom headers, etc. Bodies are fine because Content-Type: text/plain; charset=UTF-8 tells the client how to decode the bytes — but headers don't have that escape hatch.
Same principle for non-email tools: if you're sending arbitrary user content into HTTP headers (Slack posting, GitHub PRs, Webhook signatures), check /[^\x00-\x7F]/ first and escape per the protocol — or reach for env.helpers.rfc2047 if encoded-word format applies.
Other Worked Examples
- Calendar create event with full attendee management: POST
https://www.googleapis.com/calendar/v3/calendars/primary/eventswith the access token; same refresh pattern. - Drive upload file (not just app's-own-files): requires
driveordrive.filescope; multipart upload tohttps://www.googleapis.com/upload/drive/v3/files. - Slack post-message: different upstream entirely. Replace token endpoint with Slack's, use
chat.postMessageAPI. Same helper pattern, different URL constants.
When Refresh Tokens Rotate or Expire
If your OAuth client is in Testing mode (Google), refresh tokens expire after 7 days for sensitive/restricted scopes. To extend: switch the OAuth consent screen User Type to Internal (Workspace-only — long-lived refresh, no verification needed) or publish to Production (long-lived refresh, light verification for sensitive scopes; CASA + months for restricted scopes — rarely worth it for a single-customer setup).
If a refresh fails (provider revoked, secret rotated, etc.), the custom tool's fetch to the token endpoint returns 400/401. Re-run the OAuth Playground, save the new refresh token over the old variable. No code changes needed — same variable name keeps it transparent.
Documents
Documents are external knowledge sources that feed the brain. When you upload a document, the system scans it with TF-IDF (no AI) to discover its internal topic structure and creates constellation pseudo-memories that participate in the brain's thread model naturally.
read_document with the line ranges from the constellation's metadata.For a 100MB document: ~50,000 paragraphs → ~100-200 constellations → ~100-200 embedding calls (vs 50,000 for traditional chunking).
Upload Document
POST
Upload text content directly:
POST /v1/documents
Authorization: Bearer cm_YOUR_API_KEY
Content-Type: application/json
{
"brain_id": "550e8400-...",
"title": "World-Building Bible",
"content": "Chapter 1: The Seven Kingdoms..."
}
Or upload a binary file (PDF, DOCX, DOC, HTML, RTF) as base64:
{
"brain_id": "550e8400-...",
"title": "Brand Guidelines",
"filename": "brand-guidelines.pdf",
"file_base64": "JVBERi0xLjQK..."
}
Binary files are extracted server-side — PDF via pdf-parse, DOCX via mammoth, HTML via tag stripping. Title can be derived from filename.
What happens:
- Text extracted (if binary) and stored in
brain_documentswith embedding - TF-IDF scanner discovers topic structure (~67ms for 60KB)
- Constellation pseudo-memories created in the memories table
- Constellations surface during
search_memorywithdocument_sourcemetadata
List Documents
GET
GET /v1/documents?brain_id=550e8400-...
Authorization: Bearer cm_YOUR_API_KEY
Read Document Lines
MCP Tool: read_document
// When search_memory returns a constellation:
{
"content": "magic system rules constraints elemental powers...",
"type": "document_constellation",
"document_source": {
"document_id": "abc-123-...",
"document_title": "World-Building Bible",
"line_ranges": [{"start": 45, "end": 67}, {"start": 234, "end": 256}]
}
}
// Read those specific lines:
read_document(document_id: "abc-123-...", line_start: 45, line_end: 67)
Returns the raw text for those lines. The AI reads it in context and stores what it learned via add_memory, creating the training signal that helps the brain learn from documents.
reference_count increases → the training pair captures the Q&A → future searches rank this document higher.Delete Document
DELETE
DELETE /v1/documents/abc-123-...
Authorization: Bearer cm_YOUR_API_KEY
Import Training Data
POST
POST /v1/documents/import-training
Authorization: Bearer cm_YOUR_API_KEY
Content-Type: application/json
{
"title": "Sales Playbook Training",
"content": "{\"messages\":[{\"role\":\"user\",\"content\":\"...\"},{\"role\":\"assistant\",\"content\":\"...\"}]}\n..."
}
Import SFT or DPO training data as JSONL. Parsed, stored as a document, and auto-generates a lightweight skill from detected topics. Training pairs weighted at 0.1x to prevent imported data from drowning organic knowledge.
Training Export
Compile and export training data for fine-tuning external models. The training compiler uses thread detection (Neural Darwinism) to ensure contradiction-free training data — superseded memories are excluded, DPO pairs use evolution chains.
Check Readiness
GET
GET /v1/training/status
Authorization: Bearer cm_YOUR_API_KEY
Returns memory counts, training pair counts, self-sufficiency score (organic vs imported ratio), and readiness warnings. Check this before compiling — you need at least 10 training pairs for meaningful fine-tuning.
Compile & Export
POST
POST /v1/training/export
Authorization: Bearer cm_YOUR_API_KEY
Content-Type: application/json
{
"platform": "openai",
"types": ["domain_sft", "behavioral_sft", "dpo", "embedding"]
}
Supported platforms: openai, together, huggingface, axolotl, generic. Each produces the correct JSONL format for that platform's fine-tuning API.
Requires Owner access on all selected brains (training data is sensitive).
Download Dataset
GET
# Download individual datasets as JSONL files (curl-friendly)
curl -H "Authorization: Bearer cm_YOUR_API_KEY" \
"https://mcp.zombie.codes/v1/training/download/domain_sft" > domain_sft.jsonl
curl -H "Authorization: Bearer cm_YOUR_API_KEY" \
"https://mcp.zombie.codes/v1/training/download/dpo?platform=huggingface" > dpo.jsonl
Available datasets: domain_sft, behavioral_sft, dpo, embedding_triplets, imported_sft, imported_dpo, all.
Core Knowledge
Core Knowledge rules are constitutional — always active, never decayed, inherited down the brain hierarchy. They're the DNA of a brain. Unlike memories (episodic, can be superseded) or skills (behavioral, can be toggled), CK items are always-on truths that every session sees.
List Rules
GET
GET /v1/brains/550e8400-.../core-knowledge
Authorization: Bearer cm_YOUR_API_KEY
Returns all active CK items for the brain, including inherited items from parent brains. Grouped by section.
Add Rule
POST
POST /v1/brains/550e8400-.../core-knowledge
Authorization: Bearer cm_YOUR_API_KEY
Content-Type: application/json
{
"section": "writing_style",
"content": "Always write in third person limited POV. Never head-hop within a scene."
}
Creates a new CK item. If the section already has an active item, the old one is deactivated and a new version created (audit trail preserved). Visibility defaults to inherited — child brains see this rule automatically.
Remove Rule
DELETE
DELETE /v1/brains/550e8400-.../core-knowledge/ITEM_ID
Authorization: Bearer cm_YOUR_API_KEY
Deactivates the rule. Previous version preserved in audit trail.
Security
- Auth: Three methods — OAuth 2.0 (MCP + brain management), API keys (memory ops), webhook tokens (ingest-only)
- Brain isolation: Every query scoped to brain_id. No cross-brain leaks.
- Audit logging: Every operation logged (who, what, which brain, when).
- Encryption: TLS in transit, encrypted at rest (Railway).
- SOC 2: Railway is SOC 2 Type II certified. Application controls deployed.
Privacy · DPA · Skill File · support@zombie.codes