ACP support (in agent-shell within Emacs) for Letta Code
TypeScript 61%
JavaScript 27%
Emacs Lisp 12%
Shell <1%

README.md

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.

Letta agent in Emacs posting to Bluesky via social-cli skill Expanded tool call body showing console output and memory diff

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 backend

Design: 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 #

  • initialize returns agentCapabilities + two auth methods (letta-cloud, letta-local).
  • newSession spawns letta -p --input-format stream-json --output-format stream-json --include-partial-messages. Honors _meta:
    • lettaAgentId / env LETTA_AGENT_ID — pin a specific agent.
    • lettaModel / env LETTA_MODEL — e.g. "auto", "claude-opus-4-8", "openai-codex/gpt-5.4".
    • lettaPermissionMode / env LETTA_PERMISSION_MODE — standard | acceptEdits | unrestricted | memory.
    • lettaConversationId / env LETTA_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.
  • prompt streams:
    • 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_update carries {type: "diff", oldText, newText, path} for memory edits.
    • plan notification when the agent uses TodoWrite.
    • Resolves with stopReason: end_turn | cancelled | refusal.
  • cancel sends control_request: interrupt — no fallback SIGINT (keeps session alive for the next prompt).
  • request_permission round-trip: Letta can_use_tool control requests translate to ACP session/request_permission; selected/cancelled outcomes flow back as control_response { behavior: allow | deny }.
  • Real tool errors (status: error in tool_return_message, or success-with-error-body heuristic) surface as failed updates with the error text in content.

Not yet #

  • session/load + session/list (need persistent sessionId ↔ agent mapping).
  • fs/* and terminal/* 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 build

Run the adapter (for ACP clients to spawn) #

node ./dist/cli.js

The adapter speaks ACP on stdio. It expects letta to be on $PATH and either:

  • LETTA_API_KEY set (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 for sessionId.
  • Assistant + reasoning content arrive inside stream_event with event.message_type in {assistant_message, reasoning_message, …_delta}.
  • Tool calls stream via stream_event + event.message_type = "approval_request_message". First delta carries tool_call.name + tool_call.tool_call_id; subsequent deltas stream the arguments string. An auto_approval top-level wire message follows for auto-approved tools; can_use_tool fires for tools requiring human approval.
  • The terminating result message carries subtype: success | interrupted | error plus duration_ms, num_turns, final result text, 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.
  • letta CLI 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 save

After 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-buffer to reload.
  • Restart the shell session to apply config-affecting changes.

Troubleshooting #

  • Shell hangs after prompt, nothing happens: check *Messages* for adapter stderr. Letta prints Missing LETTA_API_KEY when the env var is absent.
  • letta not found: Emacs may not inherit your login shell's $PATH. Set :inherit-env t in agent-shell-make-environment-variables, or load exec-path-from-shell before starting agent-shell.
  • Cancel doesn't abort: C-c C-c sends control_request: interrupt. If the turn was already finishing server-side, Letta cloud may still be processing — the next prompt will get a CONFLICT error. A runs/<id>/cancel API 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:

  1. Emit tool_return_message in headless stream-json — wires onChunk into executeApprovalBatch so tool results appear on the wire.
  2. 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.js

Then 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 #

License #

Apache-2.0. Letta Code itself is Apache-2.0.