Concepts

Connections & secrets

Your agent needs to send from your client's mailbox and read your client's Stripe account. The naive version of this — put the token in the prompt, or in the sandbox environment — is how you end up explaining to a customer why their API key appeared in a log.

A connectionis the alternative: a named capability the harness can exercise on the agent's behalf. The agent knows the connection exists. It never learns the credential.

Four kinds, and one of them is a broker

ConnectionKind is a short list on purpose. It is what the kernel actually implements, not what it aspires to:

KindWhat it does
emailSend over HTTP to config.api_url with a bearer secret. Postmark, SendGrid and Resend all fit.
webhookPOST to a host you fixed in the connection config
customThe same HTTP transport, for anything that is neither of the above
composioBrokered: Stripe, Gmail, Xero, HubSpot, calendars and a few hundred others

There is no native Stripe executor, no SMS executor, no calendar executor. Those all run through Composio. If you were expecting a long integration catalogue in the kernel, this is the honest shape of it — two transports and a broker.

Register one

kind and name are the required fields. Everything else is optional.

curl -sX POST http://localhost:4000/v1/connections \
  -H "authorization: Bearer $MYCEL_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "kind": "email",
    "name": "billing-email",
    "config": {
      "api_url": "https://api.postmarkapp.com/email",
      "from": "billing@example.com"
    },
    "secret_ref": "env:POSTMARK_TOKEN"
  }'

The name matters more than it looks: it is what a wedge refers to in its connections array, and what blueprint provisioning matches on when deciding whether to create or reuse.

Two ways to hold the secret

secret_ref chooses which:

  • env:NAME— read from the kernel's own environment. Right for your static, founder-level credentials: your Postmark token, your Stripe key.
  • vault:KEY — read from the encrypted store. Right for anything per-client or rotatable.

Or set neither and post the value; the vault key defaults to the connection id, so the connection row never has to change:

curl -sX POST http://localhost:4000/v1/connections/<id>/secret \
  -H "authorization: Bearer $MYCEL_API_KEY" \
  -H "content-type: application/json" \
  -d '{"value": "pm_live_…"}'

Values are sealed with AES-256-GCM before they reach storage — the backend only ever sees an envelope of { v, kid, iv, tag, ct }. The kid is a fingerprint of the key that sealed it, so a mismatch after a key change is reported rather than silently returning rubbish.

Nothing reads a secret back out. There is no GET. Listing a connection returns its config, its secret_ref and a has_secret boolean — presence, not value, not length. Writing one is audited as secret.written with the connection and kind, never the value.

Set MYCEL_SECRET_KEY before you store anything you care about. Without it the kernel generates an ephemeral key per process and warns loudly: every vaulted secret becomes unreadable on restart. It must decode to exactly 32 bytes — head -c 32 /dev/urandom | base64. Losing it later means every customer re-authorises from scratch, so back it up somewhere that is not the same account as the database.

Connecting a brokered provider

Composio needs COMPOSIO_API_KEY set on the kernel. Without it every Composio route returns 501.

# browse what is available
GET  /v1/composio/toolkits?search=stripe

# create a connection and start the OAuth flow in one call
POST /v1/composio/toolkits/stripe/connect
     { "client_id": "<client>", "read_tools": ["STRIPE_LIST_INVOICES"] }
     → { connection_id, redirect_url, … }

# poll until it is live
GET  /v1/connections/<id>/composio/status   → { status: "ACTIVE", active: true }

Send the customer to redirect_url. Composio owns the callback, which is why Mycel exposes no redirect route of its own. A connection counts as connected once config.connected_account_id exists; until then, actions fail with is not connected yet — authorise <toolkit> first.

For brokered connections the capability name is the Composio tool slug — STRIPE_LIST_INVOICES, GMAIL_SEND_EMAIL. Composio's user identity is derived from the connection alone (project, and client if client-owned), so nothing the agent says can make it operate as a different tenant.

Founder-owned versus client-owned

owner is { kind: "founder" | "client", id }. This is the field that decides who a run is allowed to act as.

When a task belongs to a client, it is granted founder-level connections plus that client's own — and never another client's. That check happens when the grant is built, before the sandbox starts, so entitlement is not something the agent can talk its way past.

In practice: your Postmark account is founder-owned, and each customer's Stripe or mailbox is client-owned. Their credential runs their work and nothing else.

Reads are not actions

ReadsActions
Endpoint/v1/internal/reads/:capability/v1/internal/actions/:capability
ApprovalNoYes — human, or a matching policy rule
LimitsGET only, relative paths only, 256KB, 20s, 200 per taskWhatever the connection can do, after someone said yes
BrokeredOnly tools listed in config.read_toolsAny tool on the connected toolkit

read_tools is a real allowlist. Call a Composio tool that is not on it and you get is not a declared read for "<name>" — use the action proxy so a human approves it. The design assumption is that anything undeclared might be a write.

Channels: work that arrives

A channel binds an inbound address to a wedge and a task type. All four fields are required.

curl -sX POST http://localhost:4000/v1/channels \
  -H "authorization: Bearer $MYCEL_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "connection_id": "<email-connection-id>",
    "address": "billing@example.com",
    "wedge": "invoice-chaser",
    "task_type": "chase_invoice"
  }'

Your provider posts its webhook to your app; your app verifies the signature — Mycel does not do that for you — and forwards to POST /v1/channels/:id/inbound with { from: { handle, name }, body, subject }. The kernel resolves or creates the client, appends the message to a thread, and spawns the task with the conversation history attached. You get back { task_id, thread_id, client_id }.

Failure modes

  • email connection missing config.api_url — you registered the connection but not where to send. config is not validated at registration time.
  • no executor for connection kind "x" — you invented a kind. Use composio with the matching toolkit.
  • Secrets silently unreadable after a deploy MYCEL_SECRET_KEY changed or was never set. The log says which key sealed them.
  • Composio routes returning 501COMPOSIO_API_KEY is not set on the kernel.
  • Agent reads returning 429 — 200 reads per task, tunable with MYCEL_READ_MAX_PER_TASK. Usually it means the agent is looping.

What to read next

Wedges — where you declare which connections a service needs, and which of its actions must stop for a human.