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.
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.
han_solo/tools/@server.tool() decoratormainGET /api/admin/list-mcp-toolsCANONICAL_REN_TOOL_NAMES in han_solo/letta_client.pyensure_ren_tools() enforces the canonical set on every startupsync_mcp_tools()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.
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:
_pool in han_solo/db.py) — notecards, chat_transcripts, bridge_transcripts, skills, code_chunks, t4_entries, and most application tables._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.
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).
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.
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.
Call verify_agent_tools() and confirm:
all_match: trueattached_count reflects the new total (canonical count + 1)
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.
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.
These are not hypothetical. Each one caused a real outage or hours of debugging. Read all of them before touching tool attachment code.
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.
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.
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.
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:
get_session_brief — called back into Letta's agent loop 4 times per invocationsearch_signals — called letta.search_passages() mid-execution
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.
han-solo has two Postgres connection pools. Querying the wrong one returns empty results — no error, no warning, just silence.
han_solo/db.py → _pool): notecards, chat_transcripts, bridge_transcripts, skills, code_chunks, t4_entries, and all standard application tables.han_solo/memory_layer.py → _memory_pool): the Agent Memory Schema — session_headers, lessons, touchstones, builds, conversations, agent_identity.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.
Two conversation tables exist and they are entirely separate:
chat_transcripts — Scott ↔ Ren UI conversations. Queried by read_chat_history.bridge_transcripts — Claude/Maya ↔ Ren bridge exchanges. Queried by read_bridge_history.
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.
Before running POST /api/admin/sync-mcp-tools, ask Ren three questions:
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.
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.
Call verify_agent_tools(). Record attached_count and confirm all_match: true. This is your rollback reference.
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.
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).
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.
Commit with what changed and why. Push to main. Watch Render for a successful deploy (green status, no error in logs).
POST /api/admin/sync-mcp-tools with Bearer auth. Wait for the response confirming sync completed.
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.
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.
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. |
| 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 |