③ Answers
Agent Types
DeepAgent and BaseAgent — who runs the tool loop, what each does with a turn, and how to configure their providers, prompts and tools
Two agent runtimes ship, and the difference between them is not size — it is who runs the
tool loop. Both read the same tool registry and answer on the same
POST /api/chat.
Choosing between them
Section titled “Choosing between them”| DeepAgent — you run the engine | BaseAgent — your model consumes ours | |
|---|---|---|
| Tool loop runs | here, in your deployment | in the LLM provider, against /mcp over HTTP |
| Harness | LangChain create_agent + the deepagents middleware stack | none, on purpose |
| Virtual filesystem | yes, PostgreSQL-backed | no |
| Code execution | eval — in-process JavaScript REPL | no |
| Agent Skills | yes, through SkillsMiddleware | no |
| Charts | a fenced vis-chart block the client renders | none — the consuming client brings its own |
| Providers key | PROVIDERS_DEEP | PROVIDERS_BASE |
| Use it when | this is the default — pick it unless you have a reason not to | you are targeting Claude Desktop, Cursor, ChatGPT Apps or any MCP client |
DeepAgent is the workhorse: file reading, computation and multi-step tool use all live there. BaseAgent is deliberately the thinnest possible derivation over the MCP tool surface, so its capability matches exactly what an external MCP client gets: anything BaseAgent cannot do unaided, an outside MCP client cannot do either.
Agent selection per turn
Section titled “Agent selection per turn”Agents are discovered the same way tools are — by scanning directories at startup.
- Each directory in
AGENT_DIRSis scanned for.pyfiles subdirectories and names starting with_are skipped - A class becomes an agent by defining
generate_responseits name minus a trailingAgentis the agent name —DeepAgent→Deep - If it also defines
load_llm_clients, that runs now withPROVIDERS_{NAME}as its input, building one client per provider entry - An agent with zero clients is not offered so leaving
PROVIDERS_BASEempty is how you switch BaseAgent off -
GET /api/modelsreturns the survivingagent/providerpairs andPOST /api/chattakesagentandprovideras separate fields
curl http://localhost:18080/api/models{"success":true,"code":0,"msg":"ok","data":["Base/gemini-2.5-flash","Deep/claude-sonnet","Deep/gemini-3.5-flash"]}The client therefore chooses both halves. Omit provider and DeepAgent falls back to
DEFAULT_PROVIDER_DEEP, or to gemini-3.5-flash when that is unset; BaseAgent falls back
to gemini-2.5-flash. An agent with zero usable clients is not offered at all, so emptying
PROVIDERS_BASE is how you switch BaseAgent off.
DeepAgent
Section titled “DeepAgent”DeepAgent builds a deepagents agent per turn out of four things: the discovered tools,
a system prompt, a PostgreSQL-backed filesystem and a middleware stack.
The virtual filesystem
Section titled “The virtual filesystem”With an authenticated user, the backend is a CompositeBackend of five mounts. The
deepagents-native file tools — ls, read_file, write_file, edit_file, glob,
grep — operate on it as if it were a disk:
| Mount | Scope | Access | Holds |
|---|---|---|---|
| (default) | this session | read/write | scratch space for the turn |
/memories/ | cross-session | read/write | notes the agent chooses to keep |
/uploads/ | this session | read-only | the files attached to this request |
/library/ | cross-session | read-only | the user’s earlier parsed files |
/skills/ | packaged | read-only | the Agent Skills from SKILL_DIRS — the agent must never edit its own skills |
/uploads/ and /library/ are mirrored from the th_files table as pointers — no bytes
are copied into PostgreSQL. Parsed text is inlined so grep works; the raw bytes are
surfaced multimodally when the model reads the file. An anonymous call gets an in-memory
StateBackend instead, so nothing persists.
The middleware stack
Section titled “The middleware stack”Six middlewares wrap a DeepAgent turn, in the order they are applied:
-
ToolFaultMiddlewareOutermost, so a tool that raises is contained instead of ending the turn. -
InvalidToolCallRepairMiddlewareA tool call whose JSON never parsed is repaired rather than dropped. -
ModelCallLimitMiddlewareThe real per-turn budget:MODEL_CALL_LIMITmodel calls (default 50), then the turn ends gracefully.RECURSION_LIMITis a raw LangGraph ceiling kept only as a backstop. -
CodeInterpreterMiddlewareFromlangchain-quickjs: a persistent, in-process JavaScript REPL exposed aseval. If the package is missing the middleware is skipped with a warning and the turn runs without it. -
SkillsMiddlewareInjects every skill's frontmatter into the prompt at startup and serves the fullSKILL.mdthrough the/skills/mount only when a task calls for it. Skipped for anonymous sessions, which have no mount to read from. -
UniversalPromptCachingMiddleware(ttl="5m")Last, so its decision wins. Marks the prompt cacheable on providers that support it and is ignored on those that do not.
Two things deepagents would otherwise contribute are deliberately not present:
- No
tasksubagent. Streaming a subagent run delays every event until it finishes, and heretaskwould only ever be a no-op self-clone. It is disabled through a registered harness profile rather than throughDISALLOWED_TOOLS_DEEP. - No
write_todos.deepagents0.7 droppedTodoListMiddlewarefrom its default stack, and DeepAgent does not add it back.
The delete file tool is also excluded by name: the PostgreSQL filesystem backend does not
implement it, so it is not offered.
BaseAgent
Section titled “BaseAgent”BaseAgent has no LangChain agent loop, no middleware and no virtual filesystem. It renders a prompt, hands our MCP server to the provider, and streams the result — the tool loop belongs to the provider.
/mcp The admission rule: BaseAgent clients speak Responses-style APIs only, because the whole point is handing our MCP server to someone else’s agent loop. A provider that offers only Chat Completions is consumed through DeepAgent’s LangChain stack instead — a local function-call loop here would just duplicate it.
| Provider | Class | Default model | Server-side MCP | Stateful |
|---|---|---|---|---|
| OpenAI Responses | OpenAIResponsesClient | gpt-5-nano | yes | yes |
| Gemini Interactions | GeminiClient | gemini-2.5-flash | local function fallback | yes |
| DeepSeek Responses | DeepSeekResponsesClient | deepseek-v4-flash | no — function and server-side web_search only | no |
| DashScope Responses | DashScopeClient | qwen3.5-flash | no — its MCP accepts server_protocol: "sse", ours is streamable HTTP | yes |
Two more consequences worth knowing before you choose it:
- It trims history to the last five user turns by default (
user_message_threshold). - Its provider entries carry no
llm_type. The client class is picked from the prefix of the provider key —gemini…,gpt…,deepseek…,qwen…/dashscope…. A key matching no known prefix silently gets no client, and the provider then disappears from/api/models.
Charting
Section titled “Charting”The same question produces a different artefact depending on which agent answered.
| DeepAgent | BaseAgent | |
|---|---|---|
| Produced by | the model writing a fenced vis-chart block of pure-data JSON inline | nothing |
| Rendered by | the web client, interactively, from the JSON in that block | the consuming MCP client’s own visualization |
| Server round-trip | none — no tool call, no PNG | n/a |
The tools themselves are documented in Built-in Tools.
Configuring providers
Section titled “Configuring providers”Each agent reads its own PROVIDERS_ key, suffixed with the agent name in upper case:
PROVIDERS_DEEP: gemini-3.5-flash: llm_type: google-genai api_key: GOOGLE_API_KEY model: gemini-3.5-flash temperature: 1.0 claude-sonnet: llm_type: openai api_key: OPENROUTER_API_KEY base_url: https://openrouter.ai/api/v1 model: anthropic/claude-sonnet-4.6 temperature: 0.1 qwen-vl: llm_type: openai api_key: DASHSCOPE_API_KEY base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 model: qwen-vl-max supports_pdf: true supports_image: trueThe key of each entry is the provider name the client asks for. Inside it:
llm_type string default: openai
Which LangChain integration to initialise — google-genai or openai. Anything reached
over an OpenAI-compatible endpoint (OpenRouter, DashScope, Volcengine) is openai plus a
base_url.
api_key string
The name of the key that holds the secret — not the secret. See the warning below.
base_url string
Endpoint for OpenAI-compatible providers. A literal URL works; a name that resolves to one in the environment or config is also accepted.
model string required
The upstream model id. An entry without it is skipped at startup with a warning.
temperature number
Passed straight through, as is any other key not listed here — include_thoughts,
thinking_level and friends reach the LangChain constructor unchanged.
supports_pdf boolean
Declare only for OpenAI-compatible endpoints, whose capabilities the engine cannot infer.
true sends PDFs as a native file block; unset serves them as pre-extracted text.
supports_image boolean
Same, for images.
- api_key: GOOGLE_API_KEY
- $GOOGLE_API_KEY, else config key GOOGLE_API_KEY
- api_key: sk-abc123…
- looked up as a name, found nothing
Prompts
Section titled “Prompts”PROMPTS_{NAME} is a list of Jinja2 template paths. Each entry may pin the key the
template is registered under with a path@key suffix; without one, the key is the file
name minus .jinja:
PROMPTS_DEEP: - agent/prompts/deep.jinjaDeepAgent selects a template by the prompt_name sent with the request, falling back to the
first one configured. A per-user prompt stored through the user-prompt API takes precedence
over both.
Tool visibility
Section titled “Tool visibility”The same two keys as everywhere else, suffixed with the agent name in upper case:
| Key | Effect |
|---|---|
ALLOWED_TOOLS_{NAME} | whitelist — when set, only these tools are offered |
DISALLOWED_TOOLS_{NAME} | blacklist — applied after the whitelist, so it always wins |
The shipped config.yaml leaves every one of them empty, which means “all discovered
tools”. It also carries ALLOWED_TOOLS_RTC / DISALLOWED_TOOLS_RTC / PROMPTS_RTC
placeholders left behind by a removed agent — harmless, and a reminder that the suffix is
just an agent name.
Writing your own agent
Section titled “Writing your own agent”AGENT_DIRS is scanned the same way MCP_TOOL_DIRS is. It ships with one entry — the
packaged mirobody/agent — so add your own directory and list it first:
AGENT_DIRS: - agents # yours, scanned first - mirobody/agent # the packaged defaultThe contract is small: one class per agent, an async generator called generate_response,
and — if the agent has providers — a load_llm_clients that turns its PROVIDERS_ entry
into clients. Subclassing DeepAgent inherits both.
from typing import Any, AsyncGenerator
from mirobody.agent.deep_agent import DeepAgent
class TriageAgent(DeepAgent): """Registers as agent name "Triage"; configured with PROVIDERS_TRIAGE, ALLOWED_TOOLS_TRIAGE, DISALLOWED_TOOLS_TRIAGE and PROMPTS_TRIAGE."""
async def generate_response( self, user_id: str, messages: list[Any], **kwargs ) -> AsyncGenerator[dict[str, Any], None]: async for event in super().generate_response(user_id, messages, **kwargs): yield eventNext steps
Section titled “Next steps”How agents, tools, skills and /mcp fit together
What every agent can call, parameter by parameter
Teach an agent a procedure without writing code
Provider keys, prompts, and the three config layers