> ## Documentation Index
> Fetch the complete documentation index at: https://dev.haico.gr/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Extending HAI-Co²

> Build on HAI-Co²: add an agent tool, a whole tool domain, a typed artifact type, or an LLM provider. Each is a small, registry-driven change.

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.

| You want to add | Edits | Where |
| - | - | - |
| A **tool** in an existing domain | 1 (zero wiring) | a `@workspace_tool` method on a `*Tools` class |
| A **tool domain** | 2 | a new `*Tools` class + register it in `manager.py` |
| A **typed artifact** (chart-like) | 1 backend + 2 frontend | a tool that returns an artifact + a React renderer + a registry entry |
| An **LLM provider** | 3 | a provider module + a `Settings` key + a dispatch arm |

This page is the consolidated how-to. For the underlying mechanics see [Agent core logic §5](/docs/agent-core-logic.md)
(tool system), [§7](/docs/agent-core-logic.md) (SSE), and [§10](/docs/agent-core-logic.md) (artifacts); the
[Tools](/docs/tools.md) cheat-sheet; and [`backend/app/tools/README.md`](https://github.com/petrosrapto/HAICO/blob/main/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`](https://github.com/petrosrapto/HAICO/blob/main/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.

```python theme={null}
# backend/app/tools/todos.py
from pydantic import BaseModel, Field
from ._decorator import workspace_tool
from ..db.repositories.workspace_repository import WorkspaceRepository

class DeleteTodoArgs(BaseModel):
    todo_id: int = Field(..., description="ID of the todo step to delete.")

class TodoTools:
    def __init__(self, thread_id: str) -> None:
        self.thread_id = thread_id

    @workspace_tool(
        name="delete_todo",
        description="Permanently remove one todo step by its ID.",
        args_schema=DeleteTodoArgs,
    )
    async def delete_todo(self, repo: WorkspaceRepository, todo_id: int) -> str:
        await repo.delete_todo(self.thread_id, todo_id)
        return f"Removed todo {todo_id}."
```

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`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/tools/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`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/services/agent/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/`](https://github.com/petrosrapto/HAICO/blob/main/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`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/tools/manager.py), import it and append it to `_collections_for(thread_id)`:

```python theme={null}
from .citations import CitationTools
# ...
return [
    DocumentTools(thread_id), ObjectiveTools(thread_id), TodoTools(thread_id),
    PreferenceTools(thread_id), ChartTools(thread_id), ArtifactTools(thread_id),
    CitationTools(thread_id),   # ← new domain
]
```

`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`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/routers/query.py)) emits a dedicated `artifact` event for **any** `artifact_type`, which is what updates
the centre panel mid-stream.

```python theme={null}
# backend/app/tools/tables.py
@workspace_tool(name="data_table", description="Render structured rows as a sortable table.",
                args_schema=DataTableArgs)
async def data_table(self, repo, columns, rows, title) -> tuple[str, dict]:
    payload = {"columns": columns, "rows": rows, "title": title}
    return (f"Generated a {len(rows)}-row table.",
            {"artifact_type": "data_table", "title": title, "payload": payload})
```

**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`](https://github.com/petrosrapto/HAICO/blob/main/frontend/src/lib/api.ts)). Narrow `artifact.payload` defensively (it is typed
`unknown`); [`chart-views.tsx`](https://github.com/petrosrapto/HAICO/blob/main/frontend/src/components/app/artifacts/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`](https://github.com/petrosrapto/HAICO/blob/main/frontend/src/components/app/artifacts/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`](https://github.com/petrosrapto/HAICO/blob/main/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`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/services/llm/anthropic.py) / `cohere.py`:

```python theme={null}
def get_xyz_llm(model_id, api_key, **kwargs):
    if not api_key:
        return None
    return ChatXyz(model=model_id, api_key=api_key, **kwargs)
```

**2. Settings key**

Add the field to `Settings` in [`backend/app/core/config.py`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/core/config.py):

```python theme={null}
xyz_api_key: str = Field(default="", alias="XYZ_API_KEY")
```

**3. Dispatch arm**

In `llm.py`, import it and add an arm, and update the "Supported:" error string:

```python theme={null}
from .xyz import get_xyz_llm
# ...
elif api == "xyz":
    llm = get_xyz_llm(model_id=model_id, api_key=settings.xyz_api_key, **args)
```

### 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. On the local stack, a choice only you want goes in the gitignored `config.local.override.yaml`, which is merged over the shared `config.local.yaml`. A default model on a self-hosted OpenAI-compatible server also needs `llm.provider: openai` and the server's address in `llm.endpoint_url`, with its key, if any, in `DEFAULT_LLM_API_KEY`. That endpoint serves only the default model itself, so requests for other models never inherit it. 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`). The backend only accepts endpoints it knows and refuses every other URL, so a new endpoint needs `"<url>": "<settings_attr>"` in `ENDPOINT_API_KEY_MAP` in `llm.py` and a matching `*_api_key` field in `config.py`. The OpenAI key itself is only sent to OpenAI, for a request that names no endpoint or names OpenAI's own address, `https://api.openai.com/v1`.

**When the endpoint has no public canonical URL** (self-hosted, VPN-only, or otherwise
deployer-specific — e.g. UKP's own Ollama server), don't put it in `ENDPOINT_API_KEY_MAP` or
hardcode it as an `EP_*` constant in `providers.ts`: that would bake a private address into the
public repo. Instead: on the frontend, read it from a `NEXT_PUBLIC_*` env var (baked in at build
time) and omit the provider group entirely when it's unset, so a deployment without access to that
endpoint gets a clean catalog instead of dead entries; on the backend, add a `*_endpoint_url` +
`*_api_key` settings pair in `config.py` and match the incoming `endpoint_url` against the
configured value directly in `_resolve_openai_api_key` (`llm.py`) instead of the static map. See the
`ukp_ollama_endpoint_url` / `UKP_OLLAMA_ENDPOINT_URL` handling for a worked example, including the
VPN-required note next to the env var in `deployment/*/.env.example`.

> [!NOTE]
>
> 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. A request's `args` may only carry the generation settings in
> `ALLOWED_REQUEST_ARGS` (`llm.py`); extend that list when a provider needs another one.

## Where to read next

* [Agent core logic](/docs/agent-core-logic.md): §5 the tool system, §7 the SSE stream, §10 artifact types.
* [Tools](/docs/tools.md): the `@workspace_tool` conventions cheat-sheet.
* [`backend/app/tools/README.md`](https://github.com/petrosrapto/HAICO/blob/main/backend/app/tools/README.md): the most detailed tools how-to, including removing a tool.
* [Architecture](/docs/architecture.md): how these pieces connect at runtime.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.