This page is the consolidated how-to. For the underlying mechanics see Agent core logic §5
(tool system), §7 (SSE), and §10 (artifacts); the
Tools cheat-sheet; and
backend/app/tools/README.md.
1. Add a tool to an existing domain
A tool is anasync method on an existing *Tools class decorated with @workspace_tool. The
decorator (backend/app/tools/_decorator.py) tags the method; collect_tools walks the class,
finds every tagged method, and wraps each into a LangChain StructuredTool. Adding a method to an
already-registered class needs no wiring.
The method contract is async def fn(self, repo, **typed_kwargs) -> str (or -> (str, dict)):
self.thread_id is bound to the conversation, and repo (a WorkspaceRepository on a fresh DB
session) is injected by the wrapper, which also pops action_and_reasoning, commits on success or
rolls back on error, and returns the JSON envelope. You only write the body.
- Args schema is a Pydantic model; each
Fielddescription is the LLM’s only guidance, so make it instructive. Do not addaction_and_reasoningyourself, the decorator appends it (and the frontend shows only that sentence in the Internal Reasoning trace). For a zero-input tool, omitargs_schemaentirely (seelist_preferencesinpreferences.py). - Return a plain
str(the LLM-facing summary) or a tuple(content, artifact)whereartifactis{"artifact_type": str, "payload": dict, "title"?: str}(see §3). - Batch shape is the house style for mutations: take a list of items so one call edits many (see
add_preferences/update_todos). - Mention the new tool in the
HAICO_SYSTEM_PROMPT“Tool guide” inbuilder.pyso the agent knows when to use it.
2. Add a new tool domain
A domain is a*Tools class in its own file under backend/app/tools/. Two edits:
1
Create the class
backend/app/tools/citations.py with __init__(self, thread_id) and one @workspace_tool
method per operation (mirror charts.py / preferences.py).2
Register one instance
In
backend/app/tools/manager.py, import it and append it to _collections_for(thread_id):build_tools(thread_id) flattens collect_tools over every collection. That is the only wiring
change.
3. Add a typed artifact type (end to end)
A typed artifact is large, renderable data the agent produces but does not read back verbatim (charts are the built-in example). The backend persists and streams it; the frontend dispatches onartifact_type to a React renderer.
Backend (no new wiring beyond the tool): return the artifact tuple from a tool. The decorator
persists it via repo.add_artifact(...) tagged with the per-turn index, and the SSE layer
(query.py) emits a dedicated artifact event for any artifact_type, which is what updates
the centre panel mid-stream.
1
Write a renderer
frontend/src/components/app/artifacts/data-table-view.tsx, props { artifact } (the Artifact
type is in frontend/src/lib/api.ts). Narrow artifact.payload defensively (it is typed
unknown); chart-views.tsx shows the validate-and-fallback pattern so a bad payload
never crashes the panel.2
Register it (one line)
Add an entry to
REGISTRY in registry.tsx: data_table: DataTableView. Unknown types fall
back to an “Unknown artifact type” banner plus a JSON dump, so nothing breaks before you add the
renderer.artifact-panel.tsx, shared payload types in artifacts/types.ts,
a renderer test in artifacts/registry.test.tsx, and a “Tool guide” line in the system prompt.
Artifact rows store artifact_type as a free-form string, so no migration or allow-list is needed for
a new type.
4. Add an LLM provider or model
The model factory isbackend/app/services/llm/llm.py get_llm(config). It merges the
per-request config over settings.default_llm, then dispatches on the lowercased API field to a
provider module. Each provider module is a thin wrapper that returns a LangChain chat model (or
None if its key is missing, so the factory raises a provider-named error). This is the least
documented seam, so here it is in full.
A new native provider
1
Provider module
backend/app/services/llm/xyz.py, mirroring anthropic.py / cohere.py:2
Settings key
Add the field to
Settings in backend/app/core/config.py:3
Dispatch arm
In
llm.py, import it and add an arm, and update the “Supported:” error string:A new model on an existing provider
Usually no code: setllm.provider / llm.model in the environment’s config YAML
(deployment/{local,dev,prod}/backend/config.*.yaml, read by config.py), or pass a per-request
model config. If the new model rejects a temperature parameter, add its id prefix to
_REASONING_MODEL_PREFIXES or _ANTHROPIC_NO_TEMPERATURE_PREFIXES in llm.py (the factory strips
temperature for those).
An OpenAI-compatible endpoint
For DeepSeek / Together / xAI / vLLM and similar, setAPI=openai plus endpoint_url (injected as
base_url). To use a dedicated key instead of the OpenAI one, add "<url>": "<settings_attr>" to
ENDPOINT_API_KEY_MAP in llm.py and a matching *_api_key field in config.py.
Per-request overrides flow from the frontend through
build_agent(runtime_config) into
get_llm(model_config), which accepts either a flat {API, model_id, endpoint_url?, args} object or
a nested {"model": {...}} wrapper.Where to read next
- Agent core logic: §5 the tool system, §7 the SSE stream, §10 artifact types.
- Tools: the
@workspace_toolconventions cheat-sheet. backend/app/tools/README.md: the most detailed tools how-to, including removing a tool.- Architecture: how these pieces connect at runtime.