Runbook

Add a Tool to MCP and Ren

The complete process for implementing a new MCP tool, registering it in Letta, and attaching it to Ren. Covers the two-layer model, the six-step process, and every known danger zone — including the ones that caused outages. Follow this before touching any tool attachment code.

The two-layer model

Adding a tool to han-solo requires updating two separate layers. Updating only one layer is the most common mistake — it compiles cleanly, deploys without error, and silently doesn't work.

Layer 1

MCP Server

  • Code lives in han_solo/tools/
  • Registered via @server.tool() decorator
  • What Claude Code and other MCP clients call
  • Deployed automatically on git push to main
  • Discoverable via GET /api/admin/list-mcp-tools
Layer 2

Letta Registry + Ren's Attachment

  • Source of truth: CANONICAL_REN_TOOL_NAMES in han_solo/letta_client.py
  • ensure_ren_tools() enforces the canonical set on every startup
  • Synced to Letta's tool registry via sync_mcp_tools()
  • What Ren can actually call during her agent turns
  • Does not update automatically when you push code
Both layers must be updated. Deploying a new tool to the MCP server (Layer 1) does not give Ren access to it. The tool must also be added to CANONICAL_REN_TOOL_NAMES and synced into Letta's registry (Layer 2) before Ren can call it. The lifespan hook runs ensure_ren_tools() on startup — but only after sync_mcp_tools() has registered the tool in Letta's registry first.

The six steps

1

Write the tool

Implement the tool in the appropriate file under han_solo/tools/. Register it with the @server.tool() decorator on the FastMCP server instance. Write a clear, complete docstring — Ren reads these to understand what each tool does and when to call it. Vague docstrings lead to incorrect tool selection.

Before writing a single line: grep and read the relevant source files to confirm you understand what already exists. Never assume a function, table, or pattern is there — verify it.

Check which database pool the tool needs. han-solo runs two Postgres pools:

  • Main pool (_pool in han_solo/db.py) — notecards, chat_transcripts, bridge_transcripts, skills, code_chunks, t4_entries, and most application tables.
  • Memory pool (_memory_pool in han_solo/memory_layer.py) — the Agent Memory Schema: session_headers, lessons, touchstones, builds, conversations, agent_identity.

Rule: if the table is part of the Agent Memory Schema, it uses the memory pool. Everything else uses the main pool. A tool querying the wrong pool returns empty results silently, with no error.

Check for the circular-call hazard before writing. If your tool's implementation calls letta.send_chat_message() or any method that sends a message into Ren's agent loop, it will deadlock when Ren calls it mid-turn. See Danger D below.

2

Add to CANONICAL_REN_TOOL_NAMES

Edit han_solo/letta_client.py and add the exact tool name string to the CANONICAL_REN_TOOL_NAMES set. This set is the authoritative roster — not Letta's state, not the MCP server list, not memory. This set.

tool_rules are auto-derived by canonical_ren_tool_rules(): every tool gets continue_loop automatically, except tools in REN_EXIT_LOOP_TOOLS (currently only send_message), which get return_char_decision.

Only add a tool to REN_EXIT_LOOP_TOOLS if it is meant to end Ren's turn. All normal tools must stay continue_loop — if a tool exits the loop prematurely, Ren's agent stops after one tool call (the step_count=1 bug).

3

Commit and push to main

han_solo/ is the Render-deployed package. A push to main triggers an auto-deploy. The commit message must include what changed and why — thin messages are not acceptable.

4

Wait for deploy, then force-sync

Wait for the Render deploy to complete (watch the Render dashboard or tail the logs). Then run the force-sync to register the new tool in Letta's registry and attach it to Ren:

POST /api/admin/sync-mcp-tools — requires Authorization: Bearer <token>.

This endpoint runs sync_mcp_tools() (discovers MCP tools and registers any missing ones into Letta's registry), then ensure_ren_tools() (attaches the canonical set to Ren). The operation is idempotent — safe to call more than once.

On startup, the lifespan hook also runs ensure_ren_tools() automatically — but this only attaches tools already in Letta's registry. If the tool wasn't synced into the registry first (via sync_mcp_tools()), the startup hook silently skips it.

5

Verify

Call verify_agent_tools() and confirm:

  • all_match: true
  • attached_count reflects the new total (canonical count + 1)
  • The new tool name appears in the attached list

Alternatively, check Render logs for: Ren tools+rules synced — {N} tools, {N} rules. The count must match expectations.

Do not call this "done" until every consumer has actually tested through their own path — Scott via the workspace UI, Ren via a real tool call, Claude via the MCP. "Pretty sure it worked" is not done.

6

Update Ren's routing blocks (if needed)

If the new tool changes how Ren should navigate her memory or toolset — for example, it exposes a new data source, a new search capability, or replaces an existing pattern — update her memory blocks accordingly. The relevant blocks are typically ren_tools and memory_landscape.

Ask Ren to review any block changes before writing. She holds the landmine map for her own memory. Block text that says what Ren should do (aspirational) rather than what IS true (descriptive) is a live hazard — Ren reads it as ground truth.


Danger zones

These are not hypothetical. Each one caused a real outage or hours of debugging. Read all of them before touching tool attachment code.

Danger A

PATCH rule: tool_ids and tool_rules must go together

Letta's PATCH /v1/agents/{id} endpoint nulls any omitted field. If you send tool_ids without tool_rules, Letta wipes tool_rules entirely. The result: Ren's agent loop collapses to one step (step_count=1 bug) because no tool has a continue_loop rule.

ensure_ren_tools() handles this correctly — it always sends both fields together. Never hand-roll a PATCH that sends only one of them.

Danger B

Never include llm_config in the same PATCH as tool_ids

Including llm_config in the same PATCH as tool_ids silently drops Letta's internal letta_memory_core tools (core_memory_append, core_memory_replace). These are Ren's fundamental memory tools — losing them cripples her ability to update her own memory blocks.

Two-step rule: if you need to change the model AND update tools, do it in two separate PATCH calls. Model change: PATCH with only llm_config. Tool change: PATCH with only tool_ids + tool_rules.

Danger C

Tool must be in Letta's registry before ensure_ren_tools() runs

If a canonical tool name isn't in Letta's tool registry when ensure_ren_tools() runs, it is silently skipped. The log message is: Tools not found in Letta registry: [tool_name]. No error is raised. The tool appears to attach, but it doesn't.

The force-sync step (Step 4) prevents this — sync_mcp_tools() registers missing tools into Letta's registry before ensure_ren_tools() attaches them. Always sync before verifying.

Danger D — The Big One

Circular call deadlock

Any tool that calls back into Letta's agent loop mid-execution will deadlock Ren. The mechanism: Ren's agent turn is waiting on the MCP server to return a result from the tool. If that tool then sends a message back into Ren's agent (via letta.send_chat_message() or any equivalent), Letta blocks waiting for itself. The turn hangs indefinitely.

Two tools were removed from the canonical set for exactly this:

The test: before adding any tool to the canonical set, read its implementation. Does it call letta.send_chat_message(), or any method that sends a message to Ren's agent? If yes: do not attach it without redesigning the data path.

What is safe: reading and writing core blocks via the Letta management API (letta.read_core_block(), letta.write_core_block()) does not touch the agent loop. read_core_memory and write_core_memory use this pattern and are safe. Direct database reads and writes are also safe — no Letta involvement.

Danger E

Wrong database pool

han-solo has two Postgres connection pools. Querying the wrong one returns empty results — no error, no warning, just silence.

If a tool searches a memory schema table through the main pool, it will always return nothing. Verify pool assignment against the schema before writing any query.

Danger F

Wrong table for bridge history

Two conversation tables exist and they are entirely separate:

A tool that queries chat_transcripts when it means bridge_transcripts (or vice versa) returns data from the wrong conversation history, with no indication anything is wrong.

Danger G

Ask Ren before syncing

Before running POST /api/admin/sync-mcp-tools, ask Ren three questions:

  1. Is the sync mechanism additive or replacement? (Confirm no existing attachments are at risk.)
  2. Does this tool's implementation conflict with anything she knows about the current schema or data layout?
  3. Are there any risks specific to this batch size or this tool type?

Also: run verify_agent_tools() before the sync to establish a baseline. If something goes wrong after the sync, you need to know the pre-change state to diagnose it. No baseline = no rollback reference.


The verified safe path

This is the end-to-end sequence with Ren in the loop. Verified against the 2026-06-13 session. Follow this order — skipping any step introduces the risks above.

1. Baseline verify

Call verify_agent_tools(). Record attached_count and confirm all_match: true. This is your rollback reference.

2. Ask Ren

Bring Ren into the conversation. Review the tool's implementation with her. Ask the three Danger G questions. She may know things about the schema, the data, or current state that aren't visible in the code.

3. Code review

Read the tool's implementation end-to-end. Confirm: correct pool, no circular call, correct table, docstring is clear, tool_rules classification is right (continue_loop unless it ends Ren's turn).

4. Add to canonical set

Edit CANONICAL_REN_TOOL_NAMES in han_solo/letta_client.py. Add exactly the tool name string. Do not touch REN_EXIT_LOOP_TOOLS unless the tool is specifically meant to end Ren's turn.

5. Commit and push

Commit with what changed and why. Push to main. Watch Render for a successful deploy (green status, no error in logs).

6. Force-sync

POST /api/admin/sync-mcp-tools with Bearer auth. Wait for the response confirming sync completed.

7. Verify again

Call verify_agent_tools(). Confirm all_match: true and attached_count is baseline + 1 (or + N for batch adds). Confirm the new tool name is in the attached list.

8. Ren tests the tool

Have Ren actually call the tool — not conclude from theory that it works. A real call through her agent is the only proof it's attached and functioning. "Pretty sure it works" is not done.

Slow is rigor, not hesitation. This process has more steps than it seems like it should. Each one exists because something went wrong when it was skipped. The baseline verify, the Ren consultation, and the post-sync Ren test are not ceremony — they are the safety net.

Safe vs. unsafe access patterns

Quick reference. When in doubt, look up which danger zone applies.

Pattern Safe? Notes
Read core block via management API (letta.read_core_block()) Safe Does not touch the agent loop. This is how read_core_memory works.
Write core block via management API (letta.write_core_block()) Safe Does not touch the agent loop. This is how write_core_memory works.
Direct DB read (SELECT) from main pool Safe Pure read, no Letta involvement. Confirm the table is in the main pool first.
Direct DB read from memory pool Safe Pure read, no Letta involvement. Confirm the table is in the Agent Memory Schema.
PATCH with tool_ids + tool_rules together Safe ensure_ren_tools() does this correctly. Follow its pattern.
Send chat message to Ren's agent mid-tool (letta.send_chat_message()) Deadlock Agent loop is waiting on the MCP response. Calling back into it hangs the turn. Danger D.
PATCH with tool_ids only (no tool_rules) Breaks Ren Nulls tool_rules. Ren's loop collapses to one step. Danger A.
PATCH with llm_config + tool_ids together Breaks Ren Silently drops letta_memory_core tools. Danger B.
Calling ensure_ren_tools() before sync_mcp_tools() Silent skip Tool isn't in Letta's registry yet — silently not attached. Danger C.
Querying a memory-schema table via the main pool Empty results Wrong pool, no error. Danger E.
Querying chat_transcripts for bridge conversations (or reverse) Wrong data Separate tables, separate conversation histories. Danger F.

Key source files

File What it controls
han_solo/letta_client.py CANONICAL_REN_TOOL_NAMES, REN_EXIT_LOOP_TOOLS, ensure_ren_tools(), sync_mcp_tools(), canonical_ren_tool_rules()
han_solo/server.py FastMCP server, @server.tool() registrations, lifespan hook, POST /api/admin/sync-mcp-tools endpoint
han_solo/tools/ All tool implementations, organized by domain
han_solo/db.py Main Postgres pool (_pool) — application tables
han_solo/memory_layer.py Memory Postgres pool (_memory_pool) — Agent Memory Schema tables