jean is a Slack-native Claude Code runtime: one Slack thread = one persistent claude-agent session. jean is your AI teammate -- it lives in Slack, speaks only through an in-process MCP server, and asks a human to approve before it mutates anything outside the conversation. It runs as N stateless workers behind a single Slack app with Postgres as the shared state.
See ARCHITECTURE.md for the design (ports & adapters, trust boundary,
stateless-worker model) and CLAUDE.md for the working conventions.
uv syncCreate ~/.jean/IDENTITY.md (or set JEAN_HOME to point elsewhere). This is
a plain-text persona document -- jean's system prompt is composed from it.
At minimum, tell it who its manager is and who can approve actions, using
real Slack mentions (every id jean uses at runtime must appear verbatim in
this file -- see "Trust boundary" below):
# jean
I am jean, an AI teammate for the engineering team.
My manager is <@U0123ABCD>. I take direction from them and keep them
informed of anything important.
Approvers:
- <@U0456EFGH> approves deploys and infra changes (scope: deploy, release, infra)
- <@U0123ABCD> is the catch-all approver for anything else
I live in #eng-jean and can be DMed directly.Replace the <@U...> ids with real Slack user ids (Slack shows these when
you right-click a user's name -> "Copy member ID", or via a mention
autocomplete in a message draft).
Copy .env.example to .env and fill in:
| Variable | Required | Notes |
|---|---|---|
JEAN_SLACK_BOT_TOKEN |
yes | xoxb-..., from your Slack app's OAuth page |
JEAN_SLACK_APP_TOKEN |
yes | xapp-..., Socket Mode app-level token |
ANTHROPIC_API_KEY |
one of these two | pay-as-you-go API billing; wins if both are set |
CLAUDE_CODE_OAUTH_TOKEN |
one of these two | use a Claude Pro/Max subscription instead of API billing. Generate on a machine already logged into Claude Code: claude setup-token, then paste the sk-ant-oat01-... token here |
JEAN_DATABASE_URL |
yes (or rely on the compose default) | postgresql://user:pass@host:5432/db |
JEAN_HOME |
no | defaults to ~/.jean |
JEAN_APPROVERS |
no | comma-separated Slack user ids (U11111,U22222) used only when IDENTITY.md names nobody who can approve a given action. A safety net for a soul that fails to parse -- see "Who gets asked" below |
JEAN_IDLE_MINUTES, JEAN_APPROVAL_TTL, JEAN_PERMISSION_MODE, JEAN_HEALTH_PORT, JEAN_MODEL, JEAN_SOUL_PARSE_MODEL |
no | see .env.example for defaults |
JEAN_CLEANUP_ENABLED |
no | Postgres retention cleanup (default: true). One worker per cycle prunes, via an advisory lock. |
JEAN_SESSION_RETENTION_DAYS |
no | delete sessions idle longer than this (default: 3). The session's transcript cascades away with the row — and so do engaged_with and permission_mode, so a thread that has been quiet this long needs a fresh @jean mention to re-engage. |
JEAN_APPROVAL_RETENTION_DAYS |
no | delete resolved approvals older than this (default: 30) — the audit trail outlives the memory. Pending approvals are never pruned. |
JEAN_CLEANUP_INTERVAL_HOURS |
no | how often the sweep runs (default: 24) |
JEAN_TRANSCRIPT_MAX_MB |
no | refuse to archive a transcript bigger than this (default: 32), rather than let one pathological thread bloat the database. That thread keeps working, but only on the worker holding it — it won't resume elsewhere. |
JEAN_SETTLE_QUIET, JEAN_SETTLE_INTERVAL, JEAN_SETTLE_TIMEOUT |
no | the CLI writes a turn to its transcript write-behind, so jean waits for the file to stop changing before archiving it (defaults, in seconds: 1.0 of quiet, sampled every 0.1, capped at 10.0). Too short a quiet window archives a turn whose answer the CLI has not written yet — a cold worker then resumes a hole. |
Exactly one of ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN must be set --
these two are the only unprefixed env vars, everything else is JEAN_*.
Create a Slack app (Socket Mode, not HTTP events) with these OAuth scopes:
app_mentions:read-- so jean notices@jeanmentionschat:write-- to post/edit replieschannels:history-- to read messages in public channels (read_channel,read_thread)channels:read-- to turn#channel-nameinto the id the API wantsim:history-- to see DMsfiles:write-- to upload filesreactions:write-- to react/unreactassistant:write(optional) -- shows the "is thinking..." status in Slack's AI-assistant UI; jean degrades gracefully (silently) without it
Enable Socket Mode and generate an app-level token (xapp-...) with the
connections:write scope, plus a bot token (xoxb-...) from the OAuth page.
Install the app to your workspace, invite the bot to whatever channels it
should watch, and optionally DM it directly.
Adding scopes requires re-installing the app for them to take effect.
The read tools reach only public channels the bot has been invited to -- Slack refuses everything else, so the invite list is the real control surface. Size it knowingly: anything jean can read, jean can be asked to summarize into another thread.
Locally:
uv run jeanWith Docker Compose (includes a Postgres service):
docker compose upmcp.json (at $JEAN_HOME/mcp.json, or wherever JEAN_MCP_CONFIG_PATH points --
mount it from a Secret in production) is where you tell jean about the tools it has.
Two kinds of entry:
- stdio (has a
command) -- jean runs the server itself, once per worker, and proxies its tools in-process. Never let the CLI spawn these; seeARCHITECTURE.md. - remote (has a
url, nocommand) -- an HTTP/SSE server jean does not run. The CLI connects to it directly.
Register every HTTP API that speaks MCP. A server jean knows about surfaces as real
tools (mcp__portico__…) with real schemas, and those are auto-allowed -- the agent
calls them without an approval click. An HTTP API jean is not told about gets reached
with curl through Bash instead, and every Bash call costs a human an approval
click. The difference is not cosmetic: one unregistered MCP server turned a single
"list our open Jira tickets" question into eleven Approve clicks in four minutes, with
the agent guessing at tool names it had no schema for.
{
"mcpServers": {
"portico": {
"type": "http",
"url": "https://portico.internal.example/mcp",
"headers": { "Authorization": "Bearer ${PORTICO_ACCESS_TOKEN}" }
}
}
}A plugin can declare its own servers, in its .mcp.json, and jean registers them the
same way -- stdio ones it runs and proxies, http ones it hands to the CLI. You do not
copy a plugin's server into mcp.json by hand. A plugin's http server is keyed by the
name it declares (so "portico" → mcp__portico__…), and two servers claiming the same
name is a boot error, because that name is the tool prefix and one would shadow the other.
${VAR} and ${VAR:-default} are read from jean's environment, so a credential stays in
the environment (Vault → env, like every other jean secret) instead of being copied into
the mounted config. A ${VAR} that is unset or empty, with no default, is a boot
failure -- not a warning. jean refuses to start rather than send an empty credential,
401 on every call, and leave the agent to fall back to curl: straight back to the click
storm.
A registered server's tools are ALL auto-allowed, writes included. Registering an Atlassian MCP server means jean can file a Jira ticket with no button click. Register only servers whose token is scoped to what jean should be allowed to do -- the token is the blast radius, because the approval gate is not in this path.
jean.json (at $JEAN_HOME/jean.json, or JEAN_PLUGINS_PATH) lists the plugins jean
loads. Each entry names a marketplace repo, a plugin inside it, and a git ref:
{
"plugins": [
{ "marketplace": "git@github.com:example-org/skills.git", "plugin": "kubectl", "ref": "main" },
{
"marketplace": "https://github.com/anthropics/skills.git",
"plugin": "document-skills",
"ref": "main",
"skills": ["docx", "xlsx", "pptx"]
}
]
}The optional skills list narrows a plugin to the skills you actually want. Upstream
decides what ships together -- anthropics/skills sells 12 skills under one
example-skills name and bundles pdf in with docx/xlsx/pptx -- but every loaded
skill costs always-on context in every session, so the deployment gets the last word.
Omit it to take everything the marketplace lists. A name that the plugin doesn't offer is
a boot error, not a silent omission: a typo that quietly loaded three of four skills would
resurface much later as jean "not knowing how" to make a spreadsheet. The filter works by
rebuilding the plugin dir, so it is refused on a plugin that ships its own plugin.json
(that plugin may also carry commands, agents and MCP servers the rebuild would drop).
jean clones each marketplace once per (url, ref) and reads its
.claude-plugin/marketplace.json to find the plugin's directory -- it does not assume a
layout. Two shapes exist in the wild and both work:
- A dir per plugin (
source: "./plugins/kubectl", each with its own.claude-plugin/plugin.json) -- handed to the CLI as-is. - Declared inline (
source: "./"for every plugin, noplugin.jsonanywhere, each entry listing its own skill paths -- this isanthropics/skills). The CLI cannot load that directly, so jean builds the dir it expects in the plugin cache: a generated manifest plus a copy of exactly the skills that entry listed. Plugins sharing onesourceget one overlay each.
Transport follows the URL: git@/ssh:// clones over SSH with the ambient key,
https:// uses JEAN_MARKETPLACE_TOKEN. If a token clone fails, jean retries once
anonymously -- a token scoped to your own marketplace is rejected on a third-party public
repo, and what clones anonymously is public by definition.
refis not refreshed. A(marketplace, ref)clone is cached and reused on later boots, so a moving"main"means two pods can be running different skills. Pin a tag or commit SHA for deterministic rollouts.
The document skills need a toolchain, and it is in the image. docx, xlsx, and
pptx shell out to LibreOffice, pandoc, pdftoppm, python (openpyxl, pandas,
markitdown, …) and node (docx, pptxgenjs, sharp, …). The Dockerfile installs all
of it, deliberately: their SKILL.md files assume it is preinstalled, and installing at
turn time both costs an approval click (pip install / npm install classify as RISKY)
and lands in a filesystem that dies with the pod. Running jean outside this image without
that toolchain leaves the skills loadable but unable to produce a file.
- Mention it (
@jean ...) in any channel it's in, or DM it directly -- either makes you jean's conversation partner in that thread. - As the partner, your plain follow-ups in the same thread keep getting answered, no need to re-mention. Anyone else's messages are never delivered to jean at all -- no turn, no tokens, no database write -- until they @-mention her themselves and take over as partner.
- Only the partner can step jean back, by mentioning someone else
(
@budi can you take this?); that hands the thread off and jean stays quiet until she's mentioned again. That, or the thread goes idle. /mode <mode>-- set this thread's permission mode (default,acceptEdits,plan,bypassPermissions,dontAsk,auto)./help-- list available commands.- Before jean does anything that mutates something outside the conversation, it posts a Block Kit approval request and waits for an authorized approver to click Approve/Deny (or for the request to time out and auto-deny).
Only the people jean picks here may click Approve/Deny -- a click from anyone else is rejected. The set is chosen in code (never by the model), taking the first rung that yields anyone:
- Scope match -- every approver whose
scopekeywords appear in the action's summary (<@U0456EFGH> ... (scope: deploy, release, infra)). - Catch-all approver -- anyone
IDENTITY.mdmarks as approving anything ("is the catch-all approver for anything else"). JEAN_APPROVERS-- the env-level backstop.- The manager -- the person
IDENTITY.mdsays jean answers to.
If all four are empty, jean refuses the action and says so in the thread. It does not post buttons in that case: with nobody authorized, every click would be rejected and the request would simply hang until it timed out. Give jean at least a manager or a catch-all approver.
jean workers are stateless -- all session and approval state lives in
Postgres (thread <-> sdk_session_id, permission mode, pending approvals).
Any worker can handle any message for any thread; turns on the same thread
are serialized via a Postgres advisory lock, and approvals resolved on one
worker wake up whoever is waiting on another worker via LISTEN/NOTIFY.
Scale by running more replicas behind the same Slack app:
docker compose up --scale jean=3jean's persona (IDENTITY.md) is projected into structured data (manager,
approvers, allowed channels, etc.) by an LLM extraction step, but the LLM
never makes a security decision. Every Slack id that extraction produces
must appear verbatim in the raw IDENTITY.md text before any code path
trusts it (persona/extract.py::assert_ids_grounded); anything invented is
rejected in favor of a deterministic regex fallback. Approver authorization
and engagement rules live in plain Python (approval/authz.py,
gateway/engagement.py), never in a prompt -- a jailbroken persona can
mislead a human approver, but it cannot redirect messages to a different
channel/user or approve its own actions.