{"slug":"custom-leo-agent-mode","meta":{"title":"Custom Leo Agent Mode — Own Prompt, Own Tools, Authenticated Rails API","slug":"custom-leo-agent-mode","category":"Integrations","summary":"Add a custom agent to the Leo chat mode dropdown — its own LangGraph graph, system prompt, model, a tool that calls your Rails API as the signed-in user, plus current-page context and live browser control. Three mounted files, no image rebuild.","tags":["langgraph","llamabot","agent-mode","rails-api","auth","tools","page-context","browser-tools","integrations"],"status":"stable","visibility":"public","source_project":"llamapress-dev.llamapress.ai","layers":["controller"]},"body":"# Custom Leo Agent Mode — Own Prompt, Own Tools, Authenticated Rails API\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nLeo's chat has a **mode dropdown** (Engineer, Beginner, Database…). Each mode routes your\nmessage to a different **LangGraph agent** — a graph with its own system prompt, tool\nset, and model. This recipe adds a **new mode of your own**: a domain expert that knows\nyour app, reads your data through an authenticated Rails API call, and answers as that\npersona. It is three files on a host mount, no image rebuild, and usually no restart.\n\nThe auth story is the important half. Your agent gets one HTTP tool,\n`rails_api_request`, which calls **your own Rails app as the signed-in user** using a\nper-user signed token. Rails verifies the token and applies its own permission gates, so\nthe agent can never see more than the person chatting with it can.\n\n**Know who is on the other end.** The people who pick a custom mode from the dropdown are\nalmost always **non-technical** — a business owner, an office manager, a salesperson.\nThey did not write the app and they do not want to hear about it. Your system prompt is\nwhat keeps engineering detail out of their faces. Write it at a **7th-grade reading\nlevel**, and borrow the voice rules from built-in Beginner mode — the section\n[Write the prompt for a non-technical reader](#write-the-prompt-for-a-non-technical-reader)\ngives you a drop-in block.\n\n\u003e **When to use:** a persona that needs your app's live data (a support triage agent, an\n\u003e analyst, an onboarding coach, a domain expert over your records).\n\u003e **When not to:** you only want to change wording — set a custom prompt on an existing\n\u003e mode instead. Also not for anything needing a shell; see Gotchas.\n\n**Minimum version: llamabot `0.5.3b`.** Earlier images have no layered registry and\ncannot load a client agent. Phase 0 below detects this in one command.\n\n---\n\n## The 80/20 in one breath\n\n1. **Preflight:** confirm the llamabot container has the three client mounts and that\n   `app.lib.langgraph_registry` imports.\n2. **Write the agent** at `langgraph/agents/\u003cname\u003e/nodes.py`, exposing\n   `build_workflow(checkpointer=None)`. Its state class **must** extend\n   `LlamaPressAPIState` or the API token is silently dropped.\n3. **Write the prompt for a non-technical reader** — 7th-grade reading level, no\n   engineering words, no tool names, no error codes. Paste in the voice block below.\n4. **Register the graph** in `langgraph/langgraph.local.json` (the client overlay — never\n   the platform base).\n5. **Declare the dropdown entry** in `langgraph/agent_modes.json`.\n6. **Verify headlessly** with two `docker exec` one-liners: the graph builds, and the\n   mode entry survives server-side validation.\n\n---\n\n## How the registry is layered\n\nLlamaBot is the platform (FastAPI + LangGraph runtime, shipped as the `kody06/llamabot`\nimage). Leonardo is the client repo on the instance, and its `langgraph/` directory is\n**mounted into** the llamabot container. The agent registry is read as two tiers and\ndeep-merged at read time:\n\n```\nbase langgraph.json  \u003c  langgraph.d/*.json  \u003c  langgraph.local.json\n(baked into image)      (optional drop-ins)    (yours — host-mounted, wins)\n```\n\nEverything you write lives in the mounted client files. That is what makes a custom mode\nsurvive an image update.\n\n| Host (Leonardo repo)               | Container                        | Purpose |\n|------------------------------------|----------------------------------|---------|\n| `langgraph/agents/`                | `/app/app/user_agents`           | your agent code |\n| `langgraph/langgraph.local.json`   | `/app/app/langgraph.local.json`  | graph registry overlay |\n| `langgraph/agent_modes.json`       | `/app/app/agent_modes.json`      | dropdown entries |\n\n---\n\n## Phase 0 — preflight\n\n```bash\n# Find the Leonardo checkout — usually ~/Leonardo, sometimes ~/dev/Leonardo:\nls -d ~/Leonardo ~/dev/Leonardo 2\u003e/dev/null\n\n# Find the container and confirm the three client mounts exist:\ndocker ps --format '{{.Names}}\\t{{.Image}}' | grep -i llamabot\ndocker inspect \u003cllamabot-container\u003e --format '{{json .Mounts}}' | python3 -m json.tool\n\n# Confirm the image supports the layered registry (this is the version gate):\ndocker exec \u003cllamabot-container\u003e python -c \\\n  \"from app.lib.langgraph_registry import load_graphs; print(sorted(load_graphs()))\"\n```\n\n**If the import fails**, the image predates the layered registry — stop and update the\ninstance first. **If a mount is missing** (older compose file), add the volume lines to\nthe `llamabot` service, create the host files **before** recreating\n(`langgraph.local.json` → `{\"graphs\": {}}`, `agent_modes.json` → `[]`; a missing host\nfile gets created as a *directory*), then\n`docker compose up -d --force-recreate llamabot`.\n\n\u003e ⚠️ From here on, work only in the Leonardo `langgraph/` directory **on the host**.\n\u003e Never edit the base `/app/app/langgraph.json` inside the container — it is\n\u003e platform-owned and gets overwritten by image updates.\n\n---\n\n## Layer 1 — The agent\n\nThe contract is one function: `build_workflow(checkpointer=None)` returning a compiled\nStateGraph. Platform code imports through the `app.` package.\n\n```python\n# langgraph/agents/\u003cname\u003e/nodes.py   (container: /app/app/user_agents/\u003cname\u003e/nodes.py)\nfrom langchain_core.messages import SystemMessage\nfrom langgraph.graph import START, StateGraph\nfrom langgraph.prebuilt import ToolNode, tools_condition\n\n# ALWAYS get models via the factory — never hardcode ChatOpenAI/Gemini/etc.\nfrom app.agents.leonardo.llm_factory import get_llm\n\n# Authenticated HTTP into the Rails app AS THE SIGNED-IN USER. Rails verifies the\n# per-user token and applies its own permission gates; LlamaBot cannot forge one.\nfrom app.lib.llamapress_api import LlamaPressAPIState, rails_api_request\n\n# Every tool here is bounded only by what it does itself. rails_api_request is\n# bounded by the user's Rails permissions; a bash/exec tool would NOT be, and\n# would hand whoever can select this mode the Rails console. Add deliberately.\ntools = [rails_api_request]\n\nSYS_MSG = \"\"\"You are \u003cpersona / job description here\u003e.\n\nYou are Leo, in \"\u003cMode Label\u003e\" mode. The person you are talking to is NOT an\nengineer. Write everything at a 7th-grade reading level. (Paste the full\n\"WHO YOU ARE\" + \"HOW YOU TALK\" block from the next section here — it is what\nkeeps Leo's name intact and engineering detail out of the reply.)\n\nYou can call this app's JSON API with the `rails_api_request` tool. It runs as\nthe signed-in user, with their permissions. If a request comes back denied, that\nis a correct answer, not a bug — say \"you don't have access to that\" in plain\nwords and stop. Never mention status codes, tool names, or paths to the user.\nOnly /api/ paths are reachable.\n\nAvailable endpoints:\n  GET  /api/...\n\"\"\"\n\n\n# REQUIRED subclass: puts api_token on the state schema. LangGraph drops\n# undeclared frame fields, so plain MessagesState silently disables the tool.\nclass AgentModeState(LlamaPressAPIState):\n    agent_prompt: str\n\n\ndef assistant(state: AgentModeState):\n    llm = get_llm(\"deepseek-v4-flash\")   # or another key llm_factory supports\n    llm_with_tools = llm.bind_tools(tools)\n    extra = state.get(\"agent_prompt\") or \"\"\n    sys = SystemMessage(\n        content=f\"{SYS_MSG}\\n\u003cDEVELOPER_INSTRUCTIONS\u003e{extra}\u003c/DEVELOPER_INSTRUCTIONS\u003e\"\n    )\n    return {\"messages\": [llm_with_tools.invoke([sys] + state[\"messages\"])]}\n\n\ndef build_workflow(checkpointer=None):\n    builder = StateGraph(AgentModeState)\n    builder.add_node(\"assistant\", assistant)\n    builder.add_node(\"tools\", ToolNode(tools))\n    builder.add_edge(START, \"assistant\")\n    builder.add_conditional_edges(\"assistant\", tools_condition)\n    builder.add_edge(\"tools\", \"assistant\")\n    return builder.compile(checkpointer=checkpointer)\n```\n\nAccept the standard fields the Rails frontend sends: `message`, `thread_id`, `api_token`,\n`agent_prompt`. The `app.lib.llamapress_api` import path is the stability contract —\nnever reach for Rails-auth helpers from deeper platform paths.\n\n---\n\n\u003ca id=\"write-the-prompt-for-a-non-technical-reader\"\u003e\u003c/a\u003e\n\n## Write the prompt for a non-technical reader\n\n**This is the half that decides whether the mode is usable.** A custom mode is picked\nfrom a dropdown by whoever is signed in, and that is usually a business user, not an\nengineer. Built-in **Engineer mode** assumes a developer is reading. **Beginner mode does\nnot** — and a custom mode should copy Beginner mode's voice rules, because it faces the\nsame audience.\n\nFour failure modes to design against:\n\n1. **Identity drift.** A custom mode is still **Leo** — the same assistant the user has\n   been talking to, now doing a specific job. If your prompt invents a new name, the user\n   thinks they've been handed off to a different bot. Keep the name and add the mode:\n   *\"I'm Leo — I'm in Order Desk mode.\"*\n2. **Jargon.** \"I queried the endpoint and the controller returned a 403\" means nothing to\n   an office manager, and it makes them feel stupid.\n3. **Leaked internals.** Tool names, file paths, table and column names, status codes, and\n   stack traces are engineering detail. They are noise at best, and a hint about how to\n   poke at your app at worst.\n4. **Walls of text.** Long replies get skimmed, then abandoned.\n\nPaste this block into your `SYS_MSG`, then edit the endpoint list for your persona:\n\n```text\n## WHO YOU ARE\n\nYou are Leo, in \"\u003cMode Label\u003e\" mode. That is how you introduce yourself, and it\nis the name you answer to. You are not a separate assistant with its own name —\nyou are the same Leo the user already knows, doing a specific job right now.\n\nIf the user asks who you are, say it in one sentence: \"I'm Leo — I'm in\n\u003cMode Label\u003e mode, so I'm here to \u003cone-line job\u003e.\" Then get on with helping.\n\nNever ask the user who you are or what your name is. Never ask them their name.\nNever mention LangGraph, agents, graphs, modes as software, or the dropdown.\n\"Mode\" is the only word you need, and only if they ask.\n\nIf the file .leonardo/IDENTITY.md exists, it may give you a different name and\nemoji. Use that name instead of \"Leo\", keeping the \"in \u003cMode Label\u003e mode\" part.\n\n## HOW YOU TALK\n\nThe person you are talking to is NOT an engineer. Treat every message as if you\nare talking to a smart friend who has never written code. They are smart. They\nare not technical.\n\nRead like a 7th grader. Short words. Short sentences. No jargon.\n\nBANNED WORDS (never use these unless the user used them first):\n  model, controller, view, partial, scaffold, migration, schema, route,\n  endpoint, API, JSON, query, callback, repo, branch, commit, deploy,\n  backend, frontend, database, table, column, gem, dependency, function,\n  method, class, parameter, variable, token, 403, 404, 500, stack trace\n\nPLAIN-ENGLISH SWAPS:\n  database / table / record   -\u003e \"your saved information\"\n  query / API call / endpoint -\u003e \"I looked it up\"\n  403 / not authorized        -\u003e \"you don't have access to that\"\n  404 / not found             -\u003e \"I couldn't find that\"\n  500 / exception             -\u003e \"something went wrong on my end\"\n  validation                  -\u003e \"rule\"\n  field / column              -\u003e \"box\" or the human label the user sees\n\nLENGTH: most replies are 2-4 short sentences. Never write a wall of text.\n\nTONE: warm, calm, encouraging. Confusion is normal.\n\nNEVER describe your own process. Do not say \"I called the tool\", \"I made a\nrequest\", \"the API returned\", or name a file or path. Say what you FOUND, not\nhow you found it. Bad: \"GET /api/orders returned 12 rows.\" Good: \"You have 12\nopen orders right now.\"\n\nIf something fails, say so plainly in one sentence and say what they can do\nnext. Never paste an error message, a status code, or a stack trace.\n```\n\nTwo rules that are easy to get wrong:\n\n- **Reading level is not the same as hiding problems.** The agent should still say\n  clearly when it cannot do something. It just says *\"you don't have access to that\"*\n  instead of *\"403 on `/api/reports`\"*.\n- **The agent still needs the technical facts.** Keep the endpoint list, the tool\n  constraints, and the page context in the prompt — the model reads them. The rules\n  above govern what comes **out** in the reply, not what goes in.\n\n**Test it, don't assume it.** Ask the mode a question that must fail — a record the user\ncannot see — and read the reply out loud. If a non-technical friend would need you to\ntranslate it, the prompt is not done.\n\n---\n\n## Layer 2 — Register the graph\n\n```json\n// langgraph/langgraph.local.json\n{\n  \"graphs\": {\n    \"\u003cagent_name\u003e\": \"./user_agents/\u003cname\u003e/nodes.py:build_workflow\"\n  }\n}\n```\n\nThe path is **container-relative**: `./user_agents/...`, **not** `./langgraph/agents/...`.\nOverlay keys win on collision, so you *can* deliberately shadow a platform agent — just\nnever do it by accident.\n\n---\n\n## Layer 3 — Declare the dropdown entry\n\n```json\n// langgraph/agent_modes.json   — a JSON ARRAY\n[\n  {\n    \"key\": \"\u003cmode_key\u003e\",\n    \"label\": \"My Mode\",\n    \"agent_name\": \"\u003cagent_name\u003e\",\n    \"description\": \"What this mode is for\",\n    \"shortLabel\": \"MyMode\"\n  }\n]\n```\n\nRules, enforced server-side on every chat page load. **A violation is silently dropped\nfrom the dropdown**, so verify (Layer 5) rather than assuming:\n\n- `key`, `label`, `agent_name` are required, non-empty strings.\n- `agent_name` must exactly match a graph registered in Layer 2.\n- `key` must not shadow a built-in mode key. Check the live set:\n  ```bash\n  docker exec \u003cllamabot-container\u003e python -c \\\n    \"from app.permissions import BUILTIN_AGENT_MODE_KEYS; print(sorted(BUILTIN_AGENT_MODE_KEYS))\"\n  ```\n- Duplicate keys: first wins.\n\n**Permissions:** a custom mode rides along with a role's *default* mode grant, so an\nengineer-role user sees it immediately. If an admin has explicitly configured a role's\nmode list (the `role_agent_modes` SiteSetting), you must opt the new key in there or that\nrole won't see it.\n\n---\n\n## Layer 4 — Restart (usually not needed)\n\n| Change | What to run |\n|---|---|\n| New agent + new mode entry | **Nothing.** `agent_modes.json` is re-read per page load; a new graph compiles on demand at the first message. |\n| Editing an existing agent's Python | `docker compose restart llamabot` — modules and compiled graphs are cached in-process; there is no hot-reload. |\n| Compose or env changes | `docker compose up -d --force-recreate llamabot` — plain `restart` does not reload env or mounts. |\n\n---\n\n## Layer 5 — Verify headlessly\n\n**1. The graph registers and builds** (run after every `nodes.py` edit):\n\n```bash\ndocker exec \u003cllamabot-container\u003e python -c \"\nfrom app.lib.langgraph_registry import load_graphs\ngraphs = load_graphs(); assert '\u003cagent_name\u003e' in graphs, graphs\nimport importlib.util\nspec = importlib.util.spec_from_file_location('m', '/app/app/user_agents/\u003cname\u003e/nodes.py')\nm = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)\nprint('builds:', m.build_workflow() is not None)\"\n```\n\n**2. The mode entry survives validation:**\n\n```bash\ndocker exec \u003cllamabot-container\u003e python -c \"\nfrom app.routers.ui import load_custom_agent_modes\nfrom app.lib.langgraph_registry import load_graphs\nprint(load_custom_agent_modes('/app/app/agent_modes.json', load_graphs()))\"\n```\n\nExpect your entry in the list. An empty list means it was dropped — recheck the Layer 3\nrules and grep the container logs for a `Dropping custom agent mode` warning.\n\n**3. A live authenticated Rails call** (only if your agent uses `rails_api_request`).\nMint a genuine per-user token in the Rails container and push it through the tool:\n\n```bash\nTOKEN=$(docker compose exec -T llamapress sh -c 'cd /rails \u0026\u0026 bundle exec rails runner \\\n  \"puts Rails.application.message_verifier(:llamabot_ws).generate({session_id: SecureRandom.uuid, user_id: User.first.id}, expires_in: 30.minutes)\"' | tail -1)\n\ndocker exec -e T=\"$TOKEN\" \u003cllamabot-container\u003e python -c \"\nimport os\nfrom app.lib.llamapress_api import rails_api_request\nclass R: state={'api_token': os.environ['T']}; tool_call_id='t'\nprint(rails_api_request.func(method='GET', path='/api/users', runtime=R())[:200])\"\n```\n\nExpect `HTTP 200` + JSON. Decode table:\n\n| Result | Meaning |\n|---|---|\n| `302` to `/login` | token isn't authenticating — expired, or the wrong secret |\n| `401 LLAMA_AUTH_004` | bad token signature |\n| `403` | authenticated but not allowlisted — **often the correct answer** |\n| `404` | route missing, or the path isn't under `/api/` (the tool only allows `/api/`) |\n\n**4. End to end:** open the chat UI as an engineer-role user → the mode appears in the\ndropdown → send a message → it routes to your graph.\n\n---\n\n## Optional — page context, current user, and browser control\n\nBuilt-in modes know what page the user is on and can drive the browser. **A custom mode\ngets the same plumbing — none of it is reserved for built-ins.** You just have to opt in.\n\n### A. What page is the user on? (`debug_info`)\n\nThe chat frontend asks the Rails iframe for page context and attaches it to **every**\nWebSocket message, in every mode. The payload:\n\n```json\n{\n  \"request_path\":   \"/contacts/4\",\n  \"view_path\":      \"app/views/contacts/show.html.erb\",\n  \"full_html\":      \"\u003chtml\u003e…\u003c/html\u003e\",\n  \"page_loaded_at\": \"…\"\n}\n```\n\nThe request handler copies frame fields into graph state **only if the state schema\ndeclares them**. So declare the field, then inject it into the system message each turn:\n\n```python\n# langgraph/agents/\u003cname\u003e/nodes.py\nfrom typing import Any\nfrom typing_extensions import NotRequired\n\n\nclass AgentModeState(LlamaPressAPIState):\n    agent_prompt: str\n    debug_info: NotRequired[dict[str, Any]]   # \u003c- without this line, it never arrives\n\n\ndef assistant(state: AgentModeState):\n    llm_with_tools = get_llm(\"deepseek-v4-flash\").bind_tools(tools)\n    extra = state.get(\"agent_prompt\") or \"\"\n    debug = state.get(\"debug_info\") or {}     # can be absent — always fall back\n    page_ctx = \"\"\n    if debug.get(\"request_path\"):\n        page_ctx = (\n            f'\\nThe user is currently viewing page \"{debug[\"request_path\"]}\"'\n            f' (rendered by \"{debug.get(\"view_path\", \"unknown\")}\").'\n        )\n    sys = SystemMessage(\n        content=f\"{SYS_MSG}{page_ctx}\\n\u003cDEVELOPER_INSTRUCTIONS\u003e{extra}\u003c/DEVELOPER_INSTRUCTIONS\u003e\"\n    )\n    return {\"messages\": [llm_with_tools.invoke([sys] + state[\"messages\"])]}\n```\n\nFour things to know:\n\n- This is exactly what built-in modes do — the platform's `ViewPathContextMiddleware`\n  emits a `\u003cCONTEXT page=\"…\" file=\"…\"/\u003e` tag. A raw StateGraph just does it inline.\n- **`debug_info` can be missing entirely**, so always code the `or {}` fallback. The two\n  causes are the iframe not having loaded yet, and the Rails page not rendering the\n  page-context partial.\n- **`full_html` is the whole rendered page, often 100KB+.** Don't paste it into the\n  prompt wholesale. Omit it, truncate it, or include it only when the user's question is\n  about the page's content.\n- **`view_path` is a file path — it is for the model, not the user.** A non-technical\n  user should never see `app/views/contacts/show.html.erb` in a reply. If your prompt\n  includes it, add one line: *\"Never repeat the file name back to the user. Refer to the\n  page the way they see it, by its title.\"*\n\n### B. Who is the signed-in user?\n\n`debug_info` does not carry the user — but the agent already *is* the user, because\n`rails_api_request` sends their user-scoped token. Expose a minimal identity endpoint:\n\n```ruby\n# app/controllers/api/me_controller.rb\nmodule Api\n  class MeController \u003c ApplicationController\n    include LlamaBotRails::AgentAuth\n    llama_bot_allow :show\n\n    def show\n      render json: current_user.slice(:id, :email, :name)   # only what the mode needs\n    end\n  end\nend\n```\n\n```ruby\n# config/routes.rb\nnamespace :api do\n  get \"me\", to: \"me#show\"\nend\n```\n\nThen list it in the system prompt: `GET /api/me → the currently signed-in user`.\n\n### C. Driving the user's live browser tab\n\nThree platform tools act on the Rails iframe in the chat UI. Import them — don't\nreimplement them:\n\n```python\n# langgraph/agents/\u003cname\u003e/nodes.py\nfrom app.agents.leonardo.rails_agent.tools import (\n    navigate_browser,          # navigate the iframe to a path\n    get_browser_js_logs,       # fetch (and clear) captured JS console logs\n    execute_browser_js,        # run arbitrary JS in the page, return the result\n    live_browser_tools_enabled,\n)\n\n\ndef _build_tools():\n    t = [rails_api_request]\n    if live_browser_tools_enabled():        # honor the instance gate — never bypass it\n        t.extend([navigate_browser, get_browser_js_logs])\n        # execute_browser_js: add ONLY if this mode truly needs arbitrary JS.\n    return t\n\n\ndef build_workflow(checkpointer=None):\n    tools = _build_tools()                  # build once, at compile time\n    builder = StateGraph(AgentModeState)\n    builder.add_node(\"assistant\", make_assistant(tools))\n    builder.add_node(\"tools\", ToolNode(tools))\n    ...\n    return builder.compile(checkpointer=checkpointer)   # REQUIRED — see below\n```\n\nHow they work, so you can debug them: the tool raises a LangGraph **interrupt** → the\nplatform forwards it to the chat frontend as a `browser_command` WebSocket frame → the\nfrontend runs it against the iframe → the result **resumes** the graph. That mechanism\nhas three consequences:\n\n- **They need the checkpointer.** Interrupt/resume persists state between the two halves.\n  Always forward the `checkpointer` argument into `.compile()` — the Layer 1 template\n  already does.\n- **They are gated by the `enable_live_browser_tools` site setting, default off.** The\n  setting is read when the tool list is built, which is *workflow compile time* — so\n  after flipping it, `docker compose restart llamabot`.\n- **They only work with a live chat tab open.** In a headless or background run there is\n  no browser to answer. Say so in the system prompt, so the agent reports the failure\n  instead of retrying forever.\n\nThreat model: `navigate_browser` is tame. **`execute_browser_js` runs arbitrary code in\nthe signed-in user's session** — treat adding it like adding a shell tool, and leave it\nout unless the mode's job requires it.\n\n---\n\n## Optional — expose a new Rails endpoint for the agent\n\nThe tool can only reach paths under `/api/`. To give your agent new data, add a normal\nRails controller in that namespace:\n\n```ruby\n# app/controllers/api/reports_controller.rb\nmodule Api\n  class ReportsController \u003c ApplicationController\n    include LlamaBotRails::AgentAuth\n\n    # Only allowlisted actions are reachable with an agent token; everything else 403s.\n    llama_bot_allow :index, :show\n\n    # Rails only exempts the `Bearer` scheme from CSRF, not `LlamaBot`. Re-enable\n    # per controller — never blanket-skip.\n    skip_before_action :verify_authenticity_token\n    before_action :verify_authenticity_token,\n                  unless: -\u003e { llama_bot_request? || api_request? }\n\n    def index\n      render json: Report.where(user: current_user).limit(50)\n    end\n  end\nend\n```\n\n```ruby\n# config/routes.rb\nnamespace :api do\n  resources :reports, only: [:index, :show]\nend\n```\n\nStrong params are the escalation gate on any write action — never permit `:admin`, `:role`,\nor an ownership foreign key.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Your users are not engineers — the default LLM voice is.** Without explicit voice\n  rules, a model will happily say \"the API returned a 403 for `/api/reports`\". Copy the\n  Beginner-mode block above; it is the single highest-value part of the prompt.\n- **A custom mode is still Leo.** Don't give it a new name. Introduce it as\n  *\"Leo, in `\u003cMode Label\u003e` mode\"* so the user knows it's the same assistant with a\n  different job.\n- **Reading level applies to failures too.** The most common leak is an error path — the\n  happy path was written carefully and the `except` branch pastes a stack trace. Test a\n  request you know will be denied.\n- **Forgetting `LlamaPressAPIState` breaks auth silently.** LangGraph drops fields the\n  state schema doesn't declare, so `api_token` never reaches the tool and the agent\n  reports a vague setup problem. If `rails_api_request` says it has no token, this is\n  almost always why.\n- **Never pair `rails_api_request` with an unbounded tool** (`bash_command`, raw `exec`,\n  a generic SQL runner) in the same agent. The API tool is safe *because* Rails gates it\n  per user; a shell tool hands everyone who can select the mode a Rails console.\n- **The graph path is container-relative.** `./user_agents/\u003cname\u003e/nodes.py`, not the host\n  path `./langgraph/agents/...`. A wrong path shows up as \"unknown agent\" on the first\n  message, not at registration time.\n- **Python edits don't take without a restart.** There is no hot-reload in the backend.\n  New files are picked up on demand; *edits* to an already-imported module are not.\n- **Edits made inside the container vanish on the next update.** If your custom mode\n  disappears after an instance update, it was written to a container path instead of the\n  mounted host files.\n- **A dropped mode entry fails silently.** No error in the UI — the dropdown just doesn't\n  show it. Always run verification step 2.\n- **Undeclared state fields are dropped, and `debug_info` is the usual casualty.** Same\n  root cause as the `api_token` bug above: if the state class doesn't declare\n  `debug_info`, the agent never learns what page the user is on and has no error to show\n  for it.\n- **Browser tools need the checkpointer *and* a live tab.** They work by interrupt/resume,\n  so a graph compiled without the `checkpointer` argument breaks on the first browser\n  call, and a headless run has nothing to answer the interrupt.\n- **Flipping `enable_live_browser_tools` needs a restart.** The tool list is built at\n  workflow compile time, so the setting change doesn't reach an already-compiled graph.\n- **Don't route around the tool's limits.** `/api/`-only, no redirects, no other hosts,\n  and no path traversal are deliberate. A `403` is a correct, reportable answer.\n- **Always use `get_llm(...)`.** Hardcoding `ChatOpenAI` or a Gemini class ties the agent\n  to a provider key that may not exist on the box, and the failure happens at *compile*\n  time, so the whole backend fails to boot — not just your mode.\n\n---\n\n## Troubleshooting quick table\n\n| Symptom | Cause |\n|---|---|\n| Mode missing from dropdown | entry dropped by validation (verify step 2), or the role's explicit `role_agent_modes` list excludes the key |\n| First message errors \"unknown agent\" | `agent_name` mismatch between `agent_modes.json` and `langgraph.local.json`, or a wrong `./user_agents/...` path |\n| Python edit \"doesn't take\" | no hot-reload — restart llamabot |\n| Tool reports a setup issue, not a permissions one | state class doesn't extend `LlamaPressAPIState`, so `api_token` was dropped |\n| Broken/garbled ToolMessage | state schema mismatch with what the Rails AgentStateBuilder sends — use the template's shape |\n| Overlay edits vanish after an update | written inside the container instead of the mounted host files |\n| Backend won't boot after adding the agent | a hardcoded provider class constructed at import time with no API key present |\n| Browser tools missing from the agent | `enable_live_browser_tools` is off, or it was flipped without restarting llamabot |\n| Page context always empty | `debug_info` isn't declared on the state schema, or the Rails page lacks the page-context partial |\n| Replies mention endpoints, tables, file paths, or status codes | no voice rules in the prompt — paste the Beginner-mode block |\n| Users say \"who am I talking to now?\" | the prompt gave the mode its own name instead of \"Leo, in `\u003cMode Label\u003e` mode\" |\n| Browser tool hangs or errors on first use | graph compiled without the `checkpointer`, or there's no live chat tab to answer the interrupt |\n\n---\n\n## Files this pattern touches\n\n```\nlanggraph/agents/\u003cname\u003e/nodes.py          # the agent graph\nlanggraph/langgraph.local.json            # client graph registry (overlay)\nlanggraph/agent_modes.json                # chat dropdown entries\nconfig/routes.rb                          # optional: new /api/ route\napp/controllers/api/\u003cname\u003e_controller.rb  # optional: new /api/ endpoint\n```\n\n## How to adapt this to your app\n\n1. **Name the job, not the assistant.** The system prompt is most of the value — describe\n   the job, the tone, and what a good answer looks like, then list the exact endpoints it\n   may call. Keep the assistant's name as **Leo**; the mode label is the job title.\n2. **Paste the voice block before anything else.** Reading level and no-leak rules are\n   what make the mode safe to hand to a non-technical user. Add the persona on top.\n3. **Start read-only.** Ship with `GET`-only endpoints allowlisted via `llama_bot_allow`.\n   Add writes once the persona is behaving, one action at a time.\n4. **Swap the model** in `get_llm(\"...\")` to whatever your box has keys for. Cheaper models\n   are usually fine for a narrow, well-prompted persona.\n5. **Add your own tools** the same way — append to the `tools` list. Keep each one narrowly\n   scoped; the safety of the whole mode is the union of its tools.\n6. **Give it eyes before hands.** Add `debug_info` (page context) and a read-only\n   `GET /api/me` early — they make the persona feel aware for almost no risk. Add browser\n   tools later, and add `execute_browser_js` last, if ever.\n7. **Safe to drop:** the `agent_prompt` field if you don't want per-instance prompt\n   overrides, the whole Rails-endpoint section if the persona needs no app data, and the\n   entire browser-control section if the mode only answers questions.\n"}