Caura / docs
Tutorials

Give a coded agent memory with Rail

Wrap a Python or TypeScript agent in Caura Rail: recall rules and facts before each turn, store what the turn taught, let a teammate agent pick it up, and see governance rules land in the prompt.

For agents you write yourself. The other tutorials drive Caura from a terminal or an MCP client. This one is for code: a Python or TypeScript program that calls a model and should remember between turns. It works against Caura Cloud and against a self-hosted server.

In about ten minutes you will:

  1. Point Rail at your Caura
  2. Run one turn and watch a fact get stored
  3. Let a second agent in the same fleet recall it
  4. Add a governance rule and see it lead the context on the next turn
  5. Decide what gets stored with your own extractor

Every code block below was executed against a running Caura server before this page was published. Pick a language and stay with it; each step has both.


Step 1 — Point Rail at your Caura

Install the SDK for your language:

pip install caura-rail        # Python 3.10+
npm install @caura/rail       # Node.js 22+

Rail reads its connection from the environment. On Caura Cloud, use your project's mc_ key; Rail asks the server which tenant the key belongs to.

export CAURA_URL=https://caura.ai
export CAURA_API_KEY=mc_xxxxxxxxxxxxxxxxxxxx

On a self-hosted server in standalone mode, use the server URL and the placeholder key; the tenant is default. Rail needs open-source release backend-v2.47.0 or later, so pin CAURA_VERSION in the server's .env.

export CAURA_URL=http://localhost:8000
export CAURA_API_KEY=standalone

Step 2 — One turn

A turn wraps one user message. Rail recalls before your code runs and writes after it finishes cleanly. The default extractor stores user lines that begin with Remember:, We use, We deploy, Our plan, or Our contract.

from caura_rail import MemoryScope, Rail, RestMemoryStore, Visibility

scope = MemoryScope(agent_id="ops-writer", fleet_id="ops-tutorial", visibility=Visibility.TEAM)

with RestMemoryStore.from_env() as store:
    rail = Rail(store, scope)
    with rail.turn("Remember: Our plan is to migrate billing to Postgres in Q4.") as turn:
        # Your model call goes here; turn.context.text is prompt-ready context.
        turn.reply = "Noted the Q4 billing migration."
    for write in turn.writes:
        print(write.status, write.id)   # written <id>; deduplicated <id> if it already existed
import { MemoryScope, Rail, RestMemoryStore } from "@caura/rail";

const scope = new MemoryScope({ agentId: "ops-writer", fleetId: "ops-tutorial", visibility: "scope_team" });
const rail = new Rail({ store: RestMemoryStore.fromEnv(process.env), scope });

const turn = await rail.turn("Remember: Our plan is to migrate billing to Postgres in Q4.", async () => {
  // Your model call goes here; the second callback argument is the context.
  return "Noted the Q4 billing migration.";
});
for (const write of turn.writes) console.log(write.status, write.id); // written <id>; deduplicated on a rerun

written means Caura stored a new memory. Run the block again and you get deduplicated with the same id: memory is persistent, and a repeated fact points at the existing one. Treat both as success.

Step 3 — A teammate recalls it

Visibility TEAM means every agent in fleet ops-tutorial can recall the fact. for_agent reuses the scope for another agent; recall fetches context without running a turn.

from caura_rail import MemoryScope, Rail, RestMemoryStore, Visibility

team = MemoryScope(agent_id="ops-writer", fleet_id="ops-tutorial", visibility=Visibility.TEAM)
with RestMemoryStore.from_env() as store:
    reader = Rail(store, team.for_agent("ops-reader"))
    context = reader.recall("When are we moving billing off the old database?")
    assert any("Postgres" in fact.content for fact in context.facts), context.facts
    print(context.text)
import assert from "node:assert/strict";
import { MemoryScope, Rail, RestMemoryStore } from "@caura/rail";

const team = new MemoryScope({ agentId: "ops-writer", fleetId: "ops-tutorial", visibility: "scope_team" });
const reader = new Rail({ store: RestMemoryStore.fromEnv(process.env), scope: team.forAgent("ops-reader") });
const context = await reader.recall("When are we moving billing off the old database?");
assert.ok(context.facts.some(f => f.content.includes("Postgres")), JSON.stringify(context.facts));
console.log(context.text);

The query shares no words with the stored sentence; recall is by meaning. Two rules keep this reliable: an agent belongs to the fleet of its first write, so use one agent id per fleet, and a broad question ranks against every fact in the fleet, so ask specific questions and raise top_k for agents that need a wide view.

Step 4 — Add a governance rule

Rules are keystones: policy the operator stores in Caura, which Rail places ahead of facts on every turn. Rail reads rules; you create them with the keystones API or the dashboard. Rules are authored by a trusted agent identity, and how you get one differs:

On Caura Cloud, your tenant mc_ key cannot author rules itself (the gateway answers HTTP 403 AGENT_NOT_REGISTERED). Mint an agent-scoped key at trust level 2 with one call, then author with it:

# Caura Cloud: mint a rule-author credential once; raw_key is shown only once
curl -s -X POST "$CAURA_URL/api/v1/admin/agent-keys/provision" \
  -H "X-API-Key: $CAURA_API_KEY" -H "Content-Type: application/json" \
  -d '{"agent_id": "ops-rule-author", "label": "ops rules", "initial_trust": 2}'
export RULE_AUTHOR_KEY=mc_...   # the raw_key from the response
curl -s -X POST "$CAURA_URL/api/v1/keystones" \
  -H "X-API-Key: $RULE_AUTHOR_KEY" -H "Content-Type: application/json" \
  -d '{"tenant_id": "<your tenant id from /api/v1/whoami>", "fleet_id": "ops-tutorial", "doc_id": "change-freeze",
       "title": "Change freeze", "content": "No production schema changes during the last week of a quarter.",
       "scope": "fleet", "weight": "high"}'

On a standalone server, promote the writer agent after its first write, then author as it:

# standalone server: promote the writer, then author one rule for the fleet
curl -s -X PATCH "$CAURA_URL/api/v1/agents/ops-writer/trust?tenant_id=default" \
  -H "X-API-Key: $CAURA_API_KEY" -H "Content-Type: application/json" \
  -d '{"trust_level": 2}'
curl -s -X POST "$CAURA_URL/api/v1/keystones" \
  -H "X-API-Key: $CAURA_API_KEY" -H "X-Tenant-ID: default" -H "X-Agent-ID: ops-writer" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id": "default", "fleet_id": "ops-tutorial", "doc_id": "change-freeze",
       "title": "Change freeze", "content": "No production schema changes during the last week of a quarter.",
       "scope": "fleet", "weight": "high"}'

The next turn from any agent in the fleet starts with the rule:

from caura_rail import MemoryScope, Rail, RestMemoryStore, Visibility

scope = MemoryScope(agent_id="ops-reader", fleet_id="ops-tutorial", visibility=Visibility.TEAM)
with RestMemoryStore.from_env() as store:
    context = Rail(store, scope).recall("Plan the billing migration cutover")
    print(context.text)
    # ### GOVERNANCE RULES
    # - Change freeze: No production schema changes during the last week of a quarter.
    #
    # ### RECALLED MEMORY
    # - Our plan is to migrate billing to Postgres in Q4.

Set require_keystones=True (Python) or requireKeystones: true (TypeScript) on a Rail when the agent must not run without its rules; a rule fetch failure then stops the turn instead of degrading it.

Step 5 — Decide what gets stored

Replace the default extractor with your own. It receives the user message and the reply and returns the facts to store; return an empty list for a recall-only agent.

from caura_rail import MemoryScope, Rail, RestMemoryStore


def decisions_only(message: str, reply: str) -> list[str]:
    return [line.strip() for line in message.splitlines() if line.lower().startswith("decision:")]


with RestMemoryStore.from_env() as store:
    rail = Rail(store, MemoryScope(agent_id="ops-writer", fleet_id="ops-tutorial"), extractor=decisions_only)
    with rail.turn("Decision: billing cutover happens on the first Sunday of December.") as turn:
        turn.reply = "Recorded."
    print([w.status for w in turn.writes])   # ['written'] or ['deduplicated']
import { MemoryScope, Rail, RestMemoryStore } from "@caura/rail";

const decisionsOnly = (message: string, _reply: string): string[] =>
  message.split("\n").map(l => l.trim()).filter(l => l.toLowerCase().startsWith("decision:"));

const rail = new Rail({
  store: RestMemoryStore.fromEnv(process.env),
  scope: new MemoryScope({ agentId: "ops-writer", fleetId: "ops-tutorial" }),
  extractor: decisionsOnly,
});
const turn = await rail.turn("Decision: billing cutover happens on the first Sunday of December.", () => "Recorded.");
console.log(turn.writes.map(w => w.status)); // ['written'] or ['deduplicated']

Facts must be 10 to 10,000 characters; shorter ones come back rejected with HTTP 422. A fact that fails on a temporary error (server unreachable, HTTP 5xx) comes back deferred and waits in an in-memory outbox; call rail.flush_outbox() / rail.flushOutbox() when connectivity returns.

Where to go next