HippoDb Agent SDK

SDK for Cradle agentsSDK 2.0.0

What a Cradle-based agent uses from the SDK, how to wire it, and what to watch for. Written for SDK 2.0.0.

On this page

A Cradle agent is a FastAPI backend that builds a BaseAgent itself, keeps its own users and chat history, and talks to HippoDb through the manager with an API key. This page covers the SDK features such an agent needs, in the order you meet them. It leaves out what the Cradle starter does not use: the Agent Creator, the agent network (the starter is networked: false), and the terminal runner (AgentRunner).

How to read it: section 1 is a map. Sections 2 to 7 are the wiring you do once. Sections 8 to 12 are what happens on every chat request. Sections 13 to 16 are configuration, diagnosis and upgrade notes.

1. At a glance#

Feature What it is for Where it lives in a Cradle project
HippoClient The connection to HippoDb: read, write, create and delete fabrics built wherever the project talks to HippoDb: the chat handler, account deletion, anchoring, the seed script
BaseAgent The tool-use loop: one chat() call per message built in backend/agent/chat.py (_make_sdk_handler), driven by the chat handler
ToolRegistry, BaseTool The tools the model can call: the SDK's memory tools and your business tools your tool modules, for example backend/agent/tools.py
ToolDiscovery Stores the tools in memory and offers the model only the relevant ones each turn built next to the registry
RoleConfig The agent's identity, system prompt and tool scopes backend/agent/role.py
Memory tools memory_remember, memory_think and the rule and conflict tools registered with get_builtin_tools()
Seeding The first knowledge in a fabric: domain concepts, intents, bridges, workflows, rules backend/agent/seed/, loaded by a seed script
Events and streaming Live text and progress for the chat UI, over SSE agent.event_queue drained by the handler
Stop and cancel A stop button, and a clean end when the client disconnects agent.stop() and task cancellation
Reasoning effort A per-message "think harder" choice in the UI chat(..., reasoning_effort=...)
Typed errors Handling a busy model, a bad key or a lost fabric without matching text LlmError family, FabricNotFoundError
Tool limits and history window Keeping every LLM call small BaseAgent(max_total_tools=..., history_window=...)

2. Install and versions#

cd backend && uv add hippodb-agent-sdk==2.0.0

3. Connect to HippoDb#

HippoClient is the only thing that talks to HippoDb. Build every one through a single helper, so the API key cannot be forgotten at a new call site (for example a make_hippo_client(settings, fabric) function in agent/fabric.py, used by the chat handler, account deletion, anchoring and the seed script):

from hippodb_agent.memory.client import HippoClient

def make_hippo_client(settings, fabric: str) -> HippoClient:
    return HippoClient(base_url=settings.hippodb_url, fabric=fabric,
                       api_key=settings.hippodb_api_key or None)

The API key#

Fabrics#

hippo = make_hippo_client(settings, fabric_name)
await hippo.create_fabric()          # idempotent: 201 whether the fabric is new or not
await hippo.delete_fabric(name)      # permanent, no undo (account deletion)
fabrics = await hippo.list_fabrics() # always a list of dicts: [{"name": ...}, ...]

A lost fabric#

If a user's fabric is deleted while the process runs, the next call raises FabricNotFoundError. It comes out of agent.chat(), discovery.discover() and index_tools() and from the tools' own HippoDb calls. The SDK recognises it from the 404 body, and only when the 404 names your fabric; a missing concept is not mistaken for it.

from hippodb_agent import FabricNotFoundError

try:
    reply = await agent.chat(text)
except FabricNotFoundError:
    await seed_load.run(settings)     # create the fabric again and reseed it
    reply = await agent.chat(text)    # retry the turn once

Data in a deleted fabric is gone. Re-seeding only makes the agent work again.

Timeouts and retries#

HippoClient defaults to a 60 second request timeout and a 10 second connect timeout. A request whose connection cannot be opened is retried twice with a short backoff; nothing else is retried, so a write is never sent twice. Both are arguments (timeout=, connect_retries=).

4. Build the agent#

Build the parts in this order. The full, tested example is in Building an Agent Without AgentRunner; this is the shape.

registry = ToolRegistry()
registry.register_many(get_builtin_tools(include_discover_more=True))   # memory tools + discover_more_tools
registry.register_many(get_manage_task_tools(settings, user_id))        # your business tools
registry.bind_all(hippo=hippo)
registry.bind_hippo(hippo)
await registry.warmup()                                                  # new in 2.0.0

discovery = ToolDiscovery(hippo, registry, extra_always_on=["manage_task"])
await discovery.index_tools()

role = RoleConfig(name=..., display_name=..., tool_scopes=["manage_"],
                  fabric=fabric_name, system_prompt=system_prompt())

agent = BaseAgent(role=role, registry=registry, discovery=discovery,
                  model=settings.model, api_key=llm_key,
                  manifest_fn=hippo.manifest, timeout=45,
                  enable_streaming=settings.enable_streaming)

Things that catch people out:

5. Tools#

Your business tools#

A tool is a class with a name, a description for the model, a JSON schema for its input, and an async execute:

from hippodb_agent import BaseTool

class ManageTask(BaseTool):
    name = "manage_task"
    description = "Create, update or close a task for the user"
    input_schema = {"type": "object", "properties": {"action": {"type": "string"}}, "required": ["action"]}
    atomic = True                      # finish even if the turn is cancelled

    async def execute(self, inp: dict) -> dict:
        ...
        return {"task_id": task.id}

The SDK's memory tools#

get_builtin_tools(include_discover_more=True) registers these for the model:

Tool What the model uses it for
memory_remember Store what the user said as a concept with facts and timeline events, in the right cluster
memory_think Recall: by concept, keywords or cluster; as_of for "what was true at that time"; temporal_direction for before and after
memory_list_concepts Browse memory by type, tag, property or label pattern
memory_learn_rule, memory_forget_rule Save or retire a behavioural rule ("always ask before deleting")
memory_list_conflicts, memory_resolve_conflict Show duplicates the engine could not merge, and apply the user's choice
memory_promote_rule, memory_approve_rule Turn a soft rule into an enforced one, and approve it (found by discovery, not always sent)
discover_more_tools Ask for more tools when the offered ones are not enough

In a Cradle project the always-on set is these memory tools (except promote and approve) plus discover_more_tools and your own extra_always_on names.

How many tools one call carries#

Each tool definition costs about 1.2k tokens and is sent again on every LLM round, so the list is bounded:

Limit Default Means
max_total_tools 35 The most definitions in one call
max_carried_tools 5 Tools kept from earlier turns, most recently used first
max_mid_turn_tools 5 Tools discover_more_tools may add in one turn
max_tools on ToolDiscovery 12 Tools discovery returns for one message

Always-on tools and the tools discovered for the current message are never dropped, so a call can pass max_total_tools when those alone are over it (the SDK logs one warning). Pass None to remove a limit. The limits apply only when discovery is on.

6. Memory: what the agent reads and writes#

Each fabric is a graph of concepts with facts (attribute and value; a new value supersedes the old one), timeline events and weighted, typed edges.

Limits to design around: facts() returns the current value and one previous value, not the full history of an attribute; there is no route to delete a single concept; the engine's identity log lives in its memory and is lost on restart; every fabric stays loaded in the engine, so memory grows with the number of fabrics.

7. Seeding#

A seed gives a new fabric its first knowledge so the agent does not start with an empty graph. A Cradle project writes it as Python modules (agent/seed/concepts.py, intents.py, bridges.py, workflows.py, rules.py, anchors.py) and loads it with scripts/seed_load.py, at registration and again at login (which also recreates a deleted fabric). The parts:

Rules to follow:

The YAML seed format and design guidance are in Pre-Education.

8. A chat turn#

reply = await agent.chat(text, conversation_id=thread_id, reasoning_effort=effort)

9. Streaming to the chat UI#

Turn streaming on in the agent, attach a queue, and drain it while chat() runs:

agent = BaseAgent(..., enable_streaming=True)
queue = asyncio.Queue()
agent.event_queue = queue
task = asyncio.create_task(agent.chat(text, conversation_id=thread_id))
while True:
    getter = asyncio.ensure_future(queue.get())
    done, _ = await asyncio.wait({getter, task}, return_when=asyncio.FIRST_COMPLETED)
    if getter not in done:               # the turn ended without another event
        getter.cancel()
        break
    event = getter.result()
    ...                                  # forward to the browser as an SSE item
    if event["type"] == "turn_end":
        break
reply = await task                       # also raises anything the turn raised
Event Fields Use it to
round_start round, max_rounds, tools_available show "thinking"
text_delta round, delta append text as it arrives
llm_response round, latency_ms, tokens_in, tokens_out, cached_tokens, tool_calls, stop_reason log usage
stream_reset round discard the text collected for that round: it failed and runs again without streaming
tool_start, tool_result tool, status, latency_ms, is_error, round show "working on..." lines
response content, turn_id, rounds, tools_used the complete answer (only when the turn completes)
turn_end status, turn_id, rounds, duration_ms close the stream, always last

Streaming needs both the flag and a queue; without either the agent uses the plain call. Events carry no conversation id, so run one turn at a time per agent. Detach the queue (agent.event_queue = None) when you stop reading. There is no error event: errors come out of chat().

Stop and disconnect#

Details: Events and Streaming.

10. Reasoning effort in the UI#

A reasoning model can think before it answers: slower and more expensive, better on hard questions. To offer it in the chat UI:

  1. Ask once whether to show the control: agent.supports_reasoning_effort() (or hippodb_agent.core.llm.supports_reasoning_effort(model) before an agent exists). It follows LiteLLM's model table, so a brand-new model may report false until LiteLLM is upgraded.
  2. Let the user pick Low, Medium, High or Off, and send it with the message. The chat request model (ChatStreamRequest in agent/routes/chat_routes.py) gets reasoning_effort: Literal["low", "medium", "high", "off"] | None = None, and your handler carries it to the agent (an extra parameter on respond(), or a field on the per-turn context object if your project has one).
  3. Pass it on: agent.chat(text, reasoning_effort=effort). None uses the agent's default (BaseAgent(reasoning_effort=...), from your LLM_REASONING_EFFORT setting); "off" turns the default off for this message. Any other value raises ValueError before the turn starts.

What to know: the SDK adds room for the thinking to the output limit and keeps Claude's signed thinking blocks with the tool calls of the turn, so it works in tool loops and with streaming. A model that cannot reason ignores the setting with one warning. The newest Claude models decide for themselves whether to think, so an easy question may show no thinking. On Anthropic, changing the effort between messages makes that one message re-read the conversation uncached.

11. Models, keys and errors#

What chat() can raise#

You catch Meaning Reasonable reply
LlmRateLimitError, LlmOverloadedError, LlmConnectionError The provider is busy or unreachable (retryable is true; retry_after may say how long) "The model is busy, please try again"
LlmTimeoutError No answer in time "That took too long, please try again"
LlmAuthError The LLM key was rejected Alert an operator; not the user's problem
LlmContextWindowError The conversation is too long for the model Start a new conversation, or shorten the history
LlmBadRequestError The provider refused the request (parameters, unknown model, blocked content) A generic failure; log it
FabricNotFoundError The user's fabric is gone Re-seed and retry once (section 3)
httpx.HTTPStatusError 401 or 403 from HippoDb The HippoDb key is missing, bad or lacks a scope Alert an operator

All the Llm...Error classes derive from LlmError (and from HippoDbError); each has model, provider, status_code, retry_after and retryable, and the LiteLLM exception is its __cause__. agent.last_turn.status is failed after any of them.

12. Behaviour worth knowing#

13. Settings: your Cradle Settings to the SDK#

The SDK does not read your .env. Your Settings reads it, and the handler passes the values on.

Already in the Cradle starter's Settings:

Setting (env) Goes to Default
HIPPODB_URL, HIPPODB_API_KEY HippoClient(base_url=, api_key=) manager URL, none
MODEL BaseAgent(model=) the starter's default model
ANTHROPIC_API_KEY, OPENAI_API_KEY, XAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, COHERE_API_KEY, MISTRAL_API_KEY BaseAgent(api_key=) for the key of the chosen model's provider none
LLM_ROUTING_MODELS, LLM_ROUTER_MODEL ModelRouter(routing_models=, router_model=) off
ENABLE_STREAMING BaseAgent(enable_streaming=) off

Add these to your Settings and pass them on if you want them (the SDK defaults apply until you do):

Setting (env) Goes to SDK default
LLM_CALL_TIMEOUT_S BaseAgent(timeout=), ModelRouter(timeout=) none (LiteLLM's 600 s)
LLM_REASONING_EFFORT BaseAgent(reasoning_effort=) none
MAX_TOTAL_TOOLS, MAX_CARRIED_TOOLS, MAX_MID_TURN_TOOLS, TOOL_LIMITS_ENABLED BaseAgent(max_total_tools=, max_carried_tools=, max_mid_turn_tools=); None removes a limit 35, 5, 5, on
HISTORY_WINDOW BaseAgent(history_window=) 5
MAX_ROUNDS BaseAgent(max_rounds=) 20
DISCOVERY_MAX_TOOLS, DISCOVERY_MIN_ACTIVATION ToolDiscovery(max_tools=, min_activation=) 12, 0.05
a turn timeout and a session-build timeout your own asyncio.wait_for around chat() and the agent build yours
how many past exchanges go in agent.history your own handler yours

Anything your project does not forward keeps the SDK default. The full list is in the technical reference, section 17.

14. Seeing what the agent did#

15. Upgrading a Cradle project from 1.x#

  1. Bump the pin (uv add hippodb-agent-sdk==2.0.0) and run the test suite. A search of a wired project found nothing it uses that 2.0.0 removed.
  2. Pass the HippoDb key at every HippoClient(...) through one helper (section 3).
  3. Remove the once-per-turn index_tools() call from the chat handler if you like; discover() does it now.
  4. Replace the litellm.request_timeout global with BaseAgent(timeout=...), and update the test that asserts the global.
  5. Handle LlmError where you used to catch LiteLLM exceptions. Code that caught litellm.RateLimitError no longer sees it.
  6. Expect a smaller history window default (5, was 20) if you rely on conversation_id history. A handler that sets agent.history itself is not affected.
  7. Add the new features you want: reasoning effort (section 10), a stop button (section 9), tool limits (section 5).
  8. Update the stale >=1.2.4 notes in comments and in SDK-LINKAGE.md, and the "SDK defaults.env" line in .env.example.

The complete list, including each removed call, is in the release notes.

16. Quick troubleshooting#

Symptom Likely cause and fix
Registration or the first chat returns 401 The HippoDb key is not reaching the client. Build every client through the helper and pass api_key= (section 3)
403 on creating a fabric The key lacks fabric:write, or the fabric cap (max_fabrics) is reached
The model never offers one of my tools tool_scopes has no prefix that matches it, the tool is not indexed (index_tools()), or it is not always-on and discovery did not pick it. Check discovery.last_index_report and the sdk.discovery log
A tool is offered but the model ignores it Its description is vague. Add keywords and examples, and say when to use it
The agent forgets earlier messages chat_history_window (or HISTORY_WINDOW) is small; older facts must come from memory, so check that memory_remember is being called
memory_think finds nothing for a past date as_of is the time a memory was stored, not an event date in its text (section 6)
The agent writes "task created" but nothing happened The model saw a history without tool traces. Keep full traces in history and do not rewrite them to prose
FabricNotFoundError after a restart or an ops action The fabric was deleted. Re-seed and retry once (section 3)
A turn hangs No timeout set. Use BaseAgent(timeout=) and wrap chat() in asyncio.wait_for
Reasoning effort has no effect The model is not a reasoning model in LiteLLM's table (one warning is logged), or it is an adaptive model that chose not to think
{manifest} renders empty manifest_fn was not passed, or refresh_manifest() was never called (section 4)
Two users see each other's tools or events One BaseAgent is serving concurrent turns. Serialise turns or use one agent per user

17. Reference documents#