Skip to main content
HAI-Co² is built around four clean extension seams. Each follows the same philosophy: declare the thing where the registry looks for it, and the framework wires the rest. Every one is a one-to-three file change. 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 an async 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.
Conventions that matter:
  • Args schema is a Pydantic model; each Field description is the LLM’s only guidance, so make it instructive. Do not add action_and_reasoning yourself, the decorator appends it (and the frontend shows only that sentence in the Internal Reasoning trace). For a zero-input tool, omit args_schema entirely (see list_preferences in preferences.py).
  • Return a plain str (the LLM-facing summary) or a tuple (content, artifact) where artifact is {"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” in builder.py so 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 on artifact_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.
Frontend (the only required new code):
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.
Optional polish: an icon/label case in 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 is backend/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: set llm.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, set API=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.