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:
- Point Rail at your Caura
- Run one turn and watch a fact get stored
- Let a second agent in the same fleet recall it
- Add a governance rule and see it lead the context on the next turn
- 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_xxxxxxxxxxxxxxxxxxxxOn 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=standaloneStep 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 existedimport { 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 rerunwritten 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
- Rail SDK integration page for the whole surface in one place.
- The Rail guide and API reference for async Python, custom stores, and every option.
- Governance & keystones for how rules are designed and managed at fleet scale.
- Harborline, a complete application built on everything above: web UI, Python and TypeScript services in one fleet, switchable between the open-source server and caura.ai.
The knowledge graph: entities, relations & graph-boosted recall
The graph view from Part 2 wasn't decoration — it's the substrate Caura builds on every write, and the reason recall finds memories you never lexically matched. Entity extraction, synonym resolution, evidence-carrying relations, and the graph-expansion search blend.
Content Policy: PII screening & the business-vs-personal gate
Configure the dashboard's Content Policy tab — screen every write for PII, PCI & secrets, and keep personal content out of the shared corporate graph.