letta-code-acp
v0.0.3
Published
Agent Client Protocol adapter for Letta Code (headless stream-json)
Maintainers
Readme
letta-code-acp
Agent Client Protocol (ACP) adapter for Letta Code. Wraps letta -p --output-format stream-json so any ACP-capable client (Zed, Emacs using agent-shell.el, …) can drive Letta agents.
Status: Working prototype. Streams s with results, permission round-trips, and memory diffs. Verified end-to-end against Letta Cloud on the Letta auto (GLM-5.1 and Minimax 2.7 mostly) "model" from Emacs.

Architecture
ACP client (Zed / agent-shell)
│ JSON-RPC over stdio
▼
letta-code-acp (this package - Node ≥18)
│ --input-format stream-json / --output-format stream-json
▼
letta -p (Letta Code CLI; runs the agent, owns tools + MemFS + skills)
│
▼
Letta Cloud or local backendDesign: treat Letta Code as the harness/runtime, expose only the ACP protocol glue. The adapter does not duplicate filesystem/terminal/permission surfaces — those stay inside Letta Code.
What works
initializereturnsagentCapabilities+ two auth methods (letta-cloud,letta-local).newSessionspawnsletta -p --input-format stream-json --output-format stream-json --include-partial-messages. Honors_meta:lettaAgentId/ envLETTA_AGENT_ID— pin a specific agent.lettaModel/ envLETTA_MODEL— e.g."auto","claude-opus-4-8","openai-codex/gpt-5.4".lettaPermissionMode/ envLETTA_PERMISSION_MODE—standard | acceptEdits | unrestricted | memory.lettaConversationId/ envLETTA_CONVERSATION_ID— resume a conversation by id;"default"attaches to the agent's primary chat.lettaReflectionTrigger+lettaReflectionStepCount— enable dreaming/reflection.newConversation: true— force a fresh conversation.
promptstreams:agent_message_chunk— assistant text.agent_thought_chunk— reasoning/thinking blocks.tool_call+tool_call_update— per-tool lifecycle with descriptive titles (Read ~/foo.ts,Bash <description>,memory · str_replace · system/prefs.md).- Memory tool diffs:
tool_call_updatecarries{type: "diff", oldText, newText, path}for memory edits. plannotification when the agent usesTodoWrite.- Resolves with
stopReason: end_turn | cancelled | refusal.
cancelsendscontrol_request: interrupt— no fallback SIGINT (keeps session alive for the next prompt).request_permissionround-trip: Lettacan_use_toolcontrol requests translate to ACPsession/request_permission; selected/cancelled outcomes flow back ascontrol_response { behavior: allow | deny }.- Real tool errors (
status: errorintool_return_message, or success-with-error-body heuristic) surface asfailedupdates with the error text in content.
Not yet
session/load+session/list(need persistentsessionId ↔ agentmapping).fs/*andterminal/*client-side routing. Letta executes tools itself; ACP host sees them passively.- MemFS conflict surfacing (#808) as a clean ACP error.
- Mid-session
setSessionModel/setSessionMode— spawn-time flags work, live swap requires upstream support. - Image input (multipart prompt) — needs upstream headless parser change.
Build
bun install # or npm install
bun run buildRun the adapter (for ACP clients to spawn)
node ./dist/cli.jsThe adapter speaks ACP on stdio. It expects letta to be on $PATH and either:
LETTA_API_KEYset (Cloud backend), or- a connected local provider (
letta connect <provider>once).
Store your key in a .env file at the repo root (gitignored):
LETTA_API_KEY=letta-...Smoke test
LETTA_API_KEY=... \
bun run smoke -- "Reply with exactly: pong" agent-<id>Drives the adapter as a child process, prints every session/update to stderr, prints the final PromptResponse.
Wire-protocol notes
Captured against letta 0.26.x with the auto model:
- System init arrives as
{ type: "system", subtype: "init", session_id, agent_id, conversation_id, model, tools, cwd, ... }. Adapter uses this as source of truth forsessionId. - Assistant + reasoning content arrive inside
stream_eventwithevent.message_typein{assistant_message, reasoning_message, …_delta}. - Tool calls stream via
stream_event+event.message_type = "approval_request_message". First delta carriestool_call.name+tool_call.tool_call_id; subsequent deltas stream theargumentsstring. Anauto_approvaltop-level wire message follows for auto-approved tools;can_use_toolfires for tools requiring human approval. - The terminating
resultmessage carriessubtype: success | interrupted | errorplusduration_ms,num_turns, finalresulttext, and aggregated usage.
See src/wire.ts for the typed subset and src/mapping.ts for the translation.
Use from Emacs (agent-shell)
This repo ships agent-shell-letta.el, an adapter for xenodium/agent-shell.
Prerequisites
- Doom Emacs (or vanilla Emacs with
use-package). - agent-shell installed.
lettaCLI on your$PATH. See letta-ai/letta-code for current install instructions. Note: this adapter requires two patches not yet merged upstream (see Upstream fork below). Without them, tool results won't appear and interrupt may be unreliable.- This repo cloned somewhere, built (
bun run build). - A Letta Cloud account and an agent ID. Sign up at letta.com, create an agent, and copy its ID from the agent settings.
packages.el (Doom)
(package! acp)
(package! agent-shell
:recipe (:host github :repo "xenodium/agent-shell"))
;; Optional: macOS notifications + file-copy helpers
(package! agent-shell-macext
:recipe (:host github :repo "cxa/agent-shell-macext" :branch "main"))config.el / agent-shell.el
;; Add letta-code-acp to load-path so agent-shell-letta is available.
(add-to-list 'load-path "~/path/to/letta-code-acp")
(require 'agent-shell-letta)
(after! agent-shell
;; Expand reasoning and tool-use folds by default — Letta turns can be long.
(setq agent-shell-thought-process-expand-by-default t
agent-shell-tool-use-expand-by-default t)
;; Letta adapter — use expand-file-name; tilde won't expand in a process command list.
(setq agent-shell-letta-acp-command
(list (expand-file-name "~/path/to/letta-code-acp/bin/letta-code-acp")))
;; Load LETTA_API_KEY from .env and inherit shell PATH (so `letta` resolves).
(setq agent-shell-letta-environment
(agent-shell-make-environment-variables
:inherit-env t
:load-env (expand-file-name "~/path/to/letta-code-acp/.env")))
;; Model and permission mode (agent-id goes in secrets.el, see below).
(setq agent-shell-letta-model "auto")
(setq agent-shell-letta-permission-mode "standard")
;; Set t to trace adapter ↔ Letta wire traffic in *Messages*.
(setq agent-shell-letta-debug nil))Store the agent ID in secrets (keep it out of version control)
Find your agent ID in the Letta Cloud UI (agent settings page) — it looks like agent-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. Store it in a file that isn't committed to your dotfiles repo:
;; secrets.el or similar — not in version control
(setq agent-shell-letta-agent-id "agent-<your-id-here>")Then M-x agent-shell-letta-start-main-chat to open a session attached
to the agent's main chat, or M-x agent-shell-letta-start-conversation
to spawn a fresh standalone conversation.
Useful commands
| Command | What it does |
|---|---|
| M-x agent-shell-letta-start-main-chat | Open a session attached to the agent's main chat |
| M-x agent-shell-letta-start-conversation | Open a session in a fresh conversation |
| M-x agent-shell-letta-set-reflection | Configure dreaming trigger + step count |
| C-c C-c (in agent-shell buffer) | Cancel the current turn |
Dev loop
Adapter (TypeScript) side:
cd letta-code-acp
bun run build:watch # rebuilds dist/ on every saveAfter a rebuild, restart the Emacs session: M-x agent-shell-letta-start-main-chat (or -start-conversation) in a fresh buffer.
Elisp side:
- Edit
agent-shell-letta.el. M-x eval-bufferto reload.- Restart the shell session to apply config-affecting changes.
Troubleshooting
- Shell hangs after prompt, nothing happens: check
*Messages*for adapter stderr. Letta printsMissing LETTA_API_KEYwhen the env var is absent. lettanot found: Emacs may not inherit your login shell's$PATH. Set:inherit-env tinagent-shell-make-environment-variables, or loadexec-path-from-shellbefore starting agent-shell.- Cancel doesn't abort:
C-c C-csendscontrol_request: interrupt. If the turn was already finishing server-side, Letta cloud may still be processing — the next prompt will get aCONFLICTerror. Aruns/<id>/cancelAPI call after interrupt is a planned fix.
Upstream fork
The adapter depends on two patches to letta-ai/letta-code not yet merged upstream. The patched fork is at codeluggage/letta-code, branch feat/headless-emit-tool-return:
- Emit
tool_return_messagein headless stream-json — wiresonChunkintoexecuteApprovalBatchso tool results appear on the wire. - Fast-path interrupt + pre-controller latch — synchronous interrupt handling so a runaway thinking turn aborts cleanly.
Both are isolated to src/headless.ts. To build and use the fork:
git clone -b feat/headless-emit-tool-return https://github.com/codeluggage/letta-code
cd letta-code
bun install && bun run build # produces letta.jsThen point the adapter at it:
LETTA_BIN=/path/to/letta-code/letta.js bun run smoke -- "ping" agent-<id>Or set LETTA_BIN in your .env file. The adapter falls back to letta on $PATH if LETTA_BIN is not set.
Acknowledgements
- letta-ai/letta-code — the agent harness this wraps.
- xenodium/agent-shell — the Emacs ACP client.
- agentclientprotocol/sdk — the ACP SDK.
- bears-ai/bear-den — an open-source Letta ACP adapter that served as design reference and inspiration.
License
Apache-2.0. Letta Code itself is Apache-2.0.
