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)

  1. Open Claude at claude.ai or the Claude desktop/mobile app
  2. Go to Settings → Connectors → Add
  3. Enter the connector URL: https://mcp.zombie.codes
  4. Click Connect — you'll be redirected to sign in with Google, GitHub, or Microsoft
  5. 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

  1. Open Cursor and go to Settings → MCP
  2. Click Add MCP Server
  3. Set the type to SSE (Server-Sent Events)
  4. Enter the URL: https://mcp.zombie.codes
  5. Save and authorize when prompted
  6. 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)

  1. Open Windsurf and go to Settings → MCP Servers
  2. Click Add Server
  3. Enter the URL: https://mcp.zombie.codes
  4. Set the transport to SSE
  5. Save and authorize when prompted
  6. 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)

  1. Open ChatGPT at chatgpt.com
  2. Go to Settings → Connected Apps or look for the MCP option in your conversation
  3. Add a new MCP connection with the URL: https://mcp.zombie.codes
  4. Authorize when prompted
  5. 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:

  1. Find the MCP server or tool server configuration in your AI tool's settings
  2. Add an SSE-type server with URL: https://mcp.zombie.codes
  3. Authorize via the OAuth popup (Google, GitHub, or Microsoft)
  4. 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 PrefixNameScopeUse Case
zp_Platform KeyAll brains under your platformYour backend server — admin ops + operational (with X-Brain-Id)
cm_API KeySingle brainMCP connections, AI sessions, direct memory operations
zwh_Webhook TokenSingle connectorIngest-only (/v1/ingest). Auto-generated with connectors.
OAuthOAuth TokenUser's accessible brainsBrain 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"
Platform keys are for third-party applications that use Zombie as invisible infrastructure. Your users never see or know about Zombie — you manage everything programmatically. See Platform API.

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 }
    ]
  }
}
FieldTypeRequiredDescription
contentstringyesThe memory content in natural language
typestringnodecision / constraint / fact / preference / observation (auto-inferred)
saliencestringnonormal / elevated / critical (auto-inferred)
target_brain_iduuidnoRoute to a specific brain. Omit for personal brain.
triggersarraynoConditions that should proactively surface this memory
session_idstringnoSession ID from load_brain (enables reconsolidation)

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 }
}
ParamTypeDefaultDescription
qstringSearch query (required)
limitint10Results per page (max 20)
offsetint0Skip N results for pagination
sourcestringauto_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.

List vs Search: /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."
ParamTypeDefaultDescription
brain_iduuidAuth brainTarget brain (platform keys must specify this)
limitint50Max results (cap: 100)
offsetint0Pagination offset
typestringFilter 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" }, ...] }
Limits: 100 memories per request. Each memory gets embedded individually (~0.5s each). For 70k items, batch into 700 requests with 1-2s delay between batches.

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-..."
}

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

Note: Most links are created automatically — 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"
}
Always provide a description. The AI uses it for routing — without a description, memories can't be routed to this brain by confidence scoring.

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...
Soft delete. Memories stay in the brain with attribution. Knowledge doesn't walk out the door.

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" }
}
FieldTypeRequiredDescription
contentstringyesThe content to ingest
source_channelstringnoSub-source: gmail, fathom, #engineering
source_userstringnoAuthor identity: user@company.com
source_idstringnoDedup key (thread_id). Same source_id = supersede previous
target_brain_iduuidnoSkip 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

Typestored_byUse caseRetrieval behavior
personalConnector ownerGmail, Outlook — one person's contentUser-biased retrieval boosts for owner
sharednullFathom, Slack — team contentEqual 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.

Mental model: the upstream service POSTs whatever shape it likes to 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_translatoractivate (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.

Why: a single Fathom transcript landing in ZB also needs to update HubSpot, get logged in a customer's analytics warehouse, ping a Slack channel — without spawning a bot for each. Routes attach declaratively; failures are logged but never block the source.

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 } }
  }
}
Why these exact fields matter:

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:

  1. Discover brains: GET /v1/brains to see IDs and descriptions
  2. Map categories: Your source system tags each item with the target brain ID
  3. Bulk import: POST /v1/memory/add/bulk with target_brain_id, 100 per request
  4. Unsure items → parent brain: The AI re-routes organically during sessions
  5. After import: Auto-routing works for ongoing ingestion (brains now have content to compare against)
Day-1 problem: Auto-routing can't work with empty brains — there's nothing to compare against. Always use explicit target_brain_id for initial imports.

AI Discovery

How any AI finds its way around:

Tier Comparison

LimitFreePro ($19/mo)Business ($39/seat, 5 min)Enterprise
Brains12510/seatCustom
Memories5,00025,00035,000/seatCustom
Recalls/month1,00010,00015,000/seatCustom
API calls/month5,00050,00075,000/seatCustom
Storage100 MB1 GB1.5 GB/seatCustom
Members11 (solo)5 min, no capCustom
Skills50200200/seatCustom
Agents32525/seatCustom
Serverless tools107575/seatCustom
Connectors52525/seatCustom
RolesUnlimitedUnlimitedUnlimitedUnlimited
CapabilityFreeProBusinessEnterprise
All core MCP tools
MCP relay
Brain hierarchy2 levelsUnlimited depthUnlimited depth
Admin dashboard
Brain analyticsPer-brainPer-brain + org-widePer-brain + org-wide
Fine-tuning export
SCIM provisioning
SSO (SAML/OIDC)
SOC 2 attestation
Dedicated support
SLA99.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.

Typical flow for an embedded app:
  1. Register your platform (one-time) → get a zp_ platform key
  2. User signs up on your app → your backend calls POST /v1/platform/brains → creates their personal brain + gets a scoped cm_ API key
  3. User creates a project → your backend creates a child brain under their personal brain
  4. Upload documentsPOST /v1/documents with X-Brain-Id header targeting the project brain
  5. AI assistant connects → MCP connection using the scoped cm_ key, automatically scoped to that brain
Auth model: Platform key (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."
}
One-time setup. The platform key is returned once. Store it in your server's environment variables — never expose it to the client.

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
Cascading delete. Removes the brain and ALL its data: memories, documents, edges, sessions, API keys. Cannot be undone.

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.

What makes an Agent: An agent has a name (identity), an agent prompt (personality that replaces default system instructions), brain assignments (which knowledge it can access, with granular per-brain scopes), tool permissions (which MCP tools are enabled), variables (encrypted API keys and config), and custom tools (serverless JS functions). Create an agent in the portal → get an MCP URL → connect from Claude, Cursor, or any MCP client.

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.

Architecture: Agent calls tool → Zombie MCP → HTTP to Tool Runner (internal) → Miniflare creates V8 isolate → executes JS with agent variables as 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.

Email triggers: inbound email is configured on a Tool, not as a separate routing layer. Enabling a tool email trigger generates a secure address for that one tool; mail to that address invokes the tool directly with a normalized email payload. The tool code owns report-specific validation, dataset reads/writes, and run logs.

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.

Security: the V8 sandbox carries only a short-lived callback token scoped to one execution. Upstream credentials never enter the sandbox. Calls bounce back to ZB at /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:

  1. Organization (Settings page) — shared by all brains and agents
  2. Brain — shared by all agents on that brain
  3. 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.

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.

Auto-finalize for MCP relays: if any MCP relay connectors are 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.

The pattern in one sentence: register any upstream MCP once (one MCP call + one user click), and that server's tools become callable by agents directly and composable inside V8 serverless tools via 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:

  1. Variable is encrypted (AES-256-GCM) and stored at the requested scope.
  2. The waiting connector auto-finalizes inside that same browser request — credential formatted into headers, encrypted, stored on the connector row.
  3. Upstream tool manifest syncs (tools/list against the upstream MCP).
  4. 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:

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:

  1. Caller identity — the calling agent's variables_encrypted, or the calling user's.
  2. 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.
  3. Owner / org — the user who owns the agent (or the user themselves), as the org-wide fallback.
Why it matters: upstream platforms (HubSpot, GitHub, etc.) already enforce ACLs against the credential they receive. Zombie Brains doesn't build a parallel ACL system — it just delivers the right credential per caller, and the upstream's permission model takes over. One connector row scales to N identities by virtue of the scope chain.

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:

  1. At create time, every slug in mcp_dependencies is validated against connectors the caller owns. Tools can't declare deps that wouldn't grant.
  2. 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_executions row.
  3. The tool runner injects an env.mcp Proxy. 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.
  4. 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.
  5. 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.
Security model: the V8 sandbox never holds upstream credentials. It carries only its own callback token, scoped to one execution. Permission checks happen at binding resolution (server-side, one place). A tool calling an undeclared slug throws a clear error listing what is declared.

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":

  1. Agent calls env.mcp.apify_xyz['search-actors']({search: 'linkedin profile'}) → list of candidate actors.
  2. Agent picks one (e.g. HarvestAPI's profile scraper), calls env.mcp.apify_xyz['fetch-actor-details']({actor: 'harvestapi/linkedin-profile-scraper'}) → input schema.
  3. Agent calls env.mcp.apify_xyz['call-actor']({actor: '...', input: {...}}) → dataset.
  4. 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.

When this pattern applies: you need an OAuth scope ZB doesn't bake in (Gmail send, full Drive read, Slack post-message, etc.) AND you're willing to register your own OAuth app on the upstream provider's developer portal. ~10 minutes of one-time setup gets you a refresh token; a ~30-line custom tool turns it into a working integration.

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.

  1. 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. Copy client_id + client_secret.
  2. 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" }
  3. 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.
  4. 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.
  5. AI verifies + writes the tool. manage(action: "list_variables", var_scope: "org") shows the new vars. Now write the custom V8 tool that reads env.MY_GMAIL_CLIENT_ID etc. — see the refresh helper + worked example below.
Total user steps: click link → paste 2 fields → submit → click Authorize on provider screen → done. Same shape as variable_links — minimal courier work between AI and user.

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.

  1. Register your OAuth client (same as above), but set the redirect URI to https://mcp.zombie.codes/oauth-playground/callback.
  2. 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.
  3. 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.
  4. 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.

HelperUse 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

Subtle bug to avoid. RFC 5322 headers (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

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.

How it works: The scanner splits the document into paragraphs, computes TF-IDF vectors, and clusters paragraphs organically (like memory threads form). Each cluster becomes a "constellation" — a keyword-smashed pseudo-memory with a single embedding. When you search memories, constellations surface alongside organic memories. To read the source material, call 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:

  1. Text extracted (if binary) and stored in brain_documents with embedding
  2. TF-IDF scanner discovers topic structure (~67ms for 60KB)
  3. Constellation pseudo-memories created in the memories table
  4. Constellations surface during search_memory with document_source metadata

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.

Training loop: Constellation surfaces → AI reads source lines → AI stores learned knowledge → document's 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


Privacy · DPA · Skill File · support@zombie.codes