Skip to content

Context pipeline

The write path: what happens between POST /api/memory and a stored row.

flowchart TB
  IN["POST /api/memory"] --> V1
  V1["1 · Pydantic validation<br/>lengths, ranges, required fields"] -->|"fail → 422"| V2
  V2["2 · type ∈ six allowed values"] -->|"fail → 422"| P
  P["3 · resolve project key<br/><b>creates it if absent</b>"] --> E
  E["4 · embed(content)"] --> W
  W["5 · INSERT row + embedding_json"] --> T
  T["6 · emit CONTEXT_STORED telemetry<br/>hashed ids, sizes — never content"] --> OUT["200 + row"]

Everything is synchronous. When the response arrives, the memory is durable and immediately retrievable.

The steps

1 — Validation

Pydantic, at the API boundary:

Field Rule
project_id, agent_id required, non-empty
content 1–8000 characters
confidence, importance 0.0 ≤ x ≤ 1.0
metadata any JSON object

2 — Type check

Checked against the six allowed values in the handler, after Pydantic:

{"detail": "type must be one of fact|decision|preference|event|constraint|relationship"}

422, and no row is written. Nothing is coerced to fact.

3 — Project resolution

The write path uses the permissive resolver: an unknown project key is created, and the request succeeds.

flowchart LR
  W["POST with project_id"] --> Q{"key exists?"}
  Q -->|yes| U["use it"]
  Q -->|no| C["CREATE, return 200"]
  R["GET with project_id"] --> Q2{"key exists?"}
  Q2 -->|yes| U2["use it"]
  Q2 -->|no| E["404"]

That asymmetry is the source of the most common integration bug: a typo on the write path returns 200 and creates a second, empty project. The later read on the correct key returns nothing, with no error anywhere in the chain. Pin the key in a constant.

4 — Embedding

Computed inline, on the request thread, from content only — not from metadata, not from type, not from agent_id.

Cost depends on the provider: negligible for hashing, a MiniLM forward pass for semantic. This is the write path's latency floor.

Every write persists an embedding. That was not always true — before 2026-08-14 no caller passed one and every row stored NULL, which meant every retrieval recomputed everything. The default now lives inside the write function, so every channel (REST, SDK, all 15 MCP tools) gets it without having to remember.

5 — Insert

One row in contexts, embedding serialised as a JSON array in embedding_json. No queue, no outbox, no deferred work.

6 — Telemetry

A CONTEXT_STORED event is emitted carrying:

Recorded Not recorded
SHA digests of project and agent ids The content
type The metadata values
content length in characters Anything readable
an estimated token count

Content is hashed, never logged. The event exists to make write volume measurable without making it readable.

The update path

flowchart TB
  P["PATCH /api/context/{id}"] --> A{"content changed?"}
  A -->|no| B["update metadata only<br/>embedding untouched"]
  A -->|yes| C["recompute embedding"] --> D["UPDATE row + version entry"]

Recomputation on content change is load-bearing. Without it an edited memory keeps its old vector and ranks on text it no longer contains — a silent correctness failure with no symptom except worse retrieval. The stale-embedding case is explicitly handled in the update path for exactly this reason.

Ownership is checked before the update: a context belonging to another organization returns 404, not 403, so the response cannot confirm that an id exists.

Write-path characteristics

Consistency Strong — readable immediately after 200
Durability Whatever your database gives you; no application-level buffer
Idempotency None. Two identical POSTs create two rows.
Deduplication None. See Memory.
Batch writes Not supported. One memory per request.
Delete Not supported per memory. PATCH to supersede, or delete the org.
Throughput ceiling One embedding computation per write

If you write in bulk

There is no batch endpoint. For an import:

# ponytail: sequential, because the ceiling is the server's embedding cost,
# not your client's concurrency. Parallelise only if you measure otherwise.
for item in items:
    requests.post(f"{BASE}/api/memory", headers=H, timeout=30, json={
        "project_id": PROJECT, "agent_id": "importer",
        "content": item["text"], "type": item["type"],
        "importance": item["importance"],
    }).raise_for_status()

Mind the production rate limit — 300 requests/minute for an authenticated caller. A large import needs pacing, or a 429 handler (Errors).