Skip to main content

The HTTP API

Everything Taisce does for your agent is a JSON call over HTTP. This page starts with the three calls every integration makes: save a turn, ask a question, and check whether memory has caught up. Then come the rest, grouped by what you want to do. It ends with every error code and a retry loop you can copy.

Using Python, Java or .NET? An adapter wraps these calls on your framework's own seam. This page is for everyone else, and for anyone who wants to see what an adapter sends.

Before you start

You need the API address and a project token, a secret key that starts with tsk_. The quickstart shows where both come from. The examples below build on this setup:

export TAISCE_URL=http://localhost:8080
export TOKEN=tsk_... # a project token
import os
import uuid

import httpx

client = httpx.Client(
base_url=os.environ["TAISCE_URL"],
headers={"Authorization": f"Bearer {os.environ['TOKEN']}"},
timeout=35.0, # a little longer than the server's own 30-second limit
)
const base = process.env.TAISCE_URL!;
const headers = {
Authorization: `Bearer ${process.env.TOKEN}`,
"Content-Type": "application/json",
};

// The server said no. Branch on `code`.
class Refusal extends Error {
constructor(readonly status: number, readonly code: string, readonly retryAfter: number | null) {
super(`${status} ${code}`);
}
}

// call sends one request and returns the parsed answer, or throws a Refusal.
async function call(method: string, path: string, body?: unknown): Promise<any> {
const res = await fetch(base + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(35_000), // a little longer than the server's own 30-second limit
});
const data = await res.json();
if (!res.ok) {
const retryAfter = res.headers.get("Retry-After");
throw new Refusal(res.status, data?.error?.code, retryAfter ? Number(retryAfter) : null);
}
return data;
}
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)

// A little longer than the server's own 30-second limit, so a slow answer is not mistaken for a lost one.
var httpClient = &http.Client{Timeout: 35 * time.Second}

// Refusal is the server saying no. Branch on Code.
type Refusal struct {
Status int
Code string
Message string
RetryAfter time.Duration
}

func (r *Refusal) Error() string { return fmt.Sprintf("%d %s: %s", r.Status, r.Code, r.Message) }

// call sends one request and decodes a successful answer into out.
func call(method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, os.Getenv("TAISCE_URL")+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := httpClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
var e struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&e)
r := &Refusal{Status: res.StatusCode, Code: e.Error.Code, Message: e.Error.Message}
if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
r.RetryAfter = time.Duration(s) * time.Second
}
return r
}
if out == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(out)
}

A few things hold for every call:

  • Your token picks the project. No request names a project, so you can't reach the wrong one.
  • Read-only tokens exist. They can ask, inspect and export. Anything that changes memory gets 403 forbidden.
  • Bodies are checked strictly. Send one JSON object of up to 1 MiB. An unknown or misspelled field, a duplicate key or broken JSON gets 400 invalid_body. Nothing is silently ignored.
  • Most reads are POST. A question, or a person's id, is personal data, and URLs end up in logs. Only GET /v1/freshness, GET /health and GET /ready are GETs.
  • Version 1 only grows. New response fields, new optional request fields and new error codes can appear. Ignore fields you don't know, and give unknown error codes a default branch.

The three calls everyone needs

Save a turn

A turn is one exchange: its messages, in order, each with the role of whoever said it. POST /v1/observations stores the turn and answers 201 straight away. Taisce reads facts out of it afterwards, in the background. That step is called formation.

curl -sS -X POST "$TAISCE_URL/v1/observations" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"idempotency_key": "'"$(uuidgen)"'",
"data_subject_id": "alice",
"messages": [
{"role": "user", "content": "I live in Cork now."},
{"role": "assistant", "content": "Noted."}
]
}'
turn = {
"idempotency_key": str(uuid.uuid4()), # one per turn; reuse it if you retry
"data_subject_id": "alice",
"messages": [
{"role": "user", "content": "I live in Cork now."},
{"role": "assistant", "content": "Noted."},
],
}
receipt = client.post("/v1/observations", json=turn).raise_for_status().json()
print(receipt["log_offset"])
const turn = {
idempotency_key: crypto.randomUUID(), // one per turn; reuse it if you retry
data_subject_id: "alice",
messages: [
{ role: "user", content: "I live in Cork now." },
{ role: "assistant", content: "Noted." },
],
};
const receipt = await call("POST", "/v1/observations", turn);
console.log(receipt.log_offset);
// uuid is github.com/google/uuid.

type Message struct {
GroupOrdinal *int `json:"group_ordinal,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
}

type Turn struct {
IdempotencyKey string `json:"idempotency_key"`
DataSubjectID string `json:"data_subject_id,omitempty"`
Messages []Message `json:"messages"`
}

type Receipt struct {
ID string `json:"id"`
Scope string `json:"scope"`
LogOffset int64 `json:"log_offset"`
}

func saveTurn() (Receipt, error) {
turn := Turn{
IdempotencyKey: uuid.NewString(), // one per turn; reuse it if you retry
DataSubjectID: "alice",
Messages: []Message{
{Role: "user", Content: "I live in Cork now."},
{Role: "assistant", Content: "Noted."},
},
}
var receipt Receipt
err := call(http.MethodPost, "/v1/observations", turn, &receipt)
return receipt, err
}

Request

FieldRequiredWhat it is
messagesyes1 to 64 messages, in the order they were said
messages[].roleyesuser, assistant, system or tool
messages[].contentyesThe words. Not blank; up to 65,536 bytes per message and 262,144 bytes per turn
messages[].group_ordinalwith tool messagesTies a tool call to its result. See tool calls
messages[].occurred_atnoWhen this message was said (RFC 3339). Defaults to the turn's time
idempotency_keyno, but send oneA UUID naming this turn, so a retry can't store it twice. See retries
data_subject_idnoWho the turn is about. Any string you choose, such as your own user id
occurred_atnoWhen the turn happened (RFC 3339). Defaults to when the server stored it

Response (201)

FieldWhat it is
idThe stored turn's id
scopeYour project
log_offsetThe turn's position in the project's log. Compare it with freshness

About data_subject_id. This is how "I" in a user's message comes to mean that user. It is a label, not a login: anyone holding the project token can use any subject. It is compared exactly, so Alice and alice are two people. A turn without a subject belongs to the whole project. That suits documents, but a person's erasure won't reach it, so erase it by its id instead. If you'd rather not use your own user ids, POST /v1/subjects/register issues a stable random one.

Ask a question

POST /v1/recalls answers from entities: the people, places and things memory knows about. It finds the names in your question, collects what is known about them, and returns each fact with the exact words it came from. No model is called, so questions keep working when your model provider is down.

curl -sS -X POST "$TAISCE_URL/v1/recalls" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"question": "Where does Alice live?", "data_subject_id": "alice"}'
bundle = client.post(
"/v1/recalls",
json={"question": "Where does Alice live?", "data_subject_id": "alice"},
).raise_for_status().json()
for fact in bundle["facts"]:
print(fact["statement"], "--", fact["evidence"]["quote"])
const bundle = await call("POST", "/v1/recalls", {
question: "Where does Alice live?",
data_subject_id: "alice",
});
for (const fact of bundle.facts) {
console.log(fact.statement, "--", fact.evidence.quote);
}
type Evidence struct {
ObservationID string `json:"observation_id"`
Quote string `json:"quote"`
ByteStart int `json:"byte_start"`
ByteEnd int `json:"byte_end"`
Context string `json:"context"`
}

type Fact struct {
FactID string `json:"fact_id"`
Predicate string `json:"predicate"`
Object string `json:"object"`
Statement string `json:"statement"`
SourceRole string `json:"source_role"`
Evidence Evidence `json:"evidence"`
}

type Bundle struct {
Facts []Fact `json:"facts"`
Truncated bool `json:"truncated"`
Reach struct {
NamedNothingKnown bool `json:"named_nothing_known"`
} `json:"reach"`
}

func ask(question, subject string) (Bundle, error) {
var b Bundle
err := call(http.MethodPost, "/v1/recalls",
map[string]any{"question": question, "data_subject_id": subject}, &b)
return b, err
}

Request

FieldRequiredWhat it is
questionyesWhat you want to know. Name the people, places or things it's about. Up to 8,192 bytes
data_subject_idnoAsk about one person. Needed for questions that say "I" or "my"

There are more options for how far to look, how big the answer may be, whose words count, and asking about the past. See recall options.

Response

FieldWhat it is
facts[]What memory knows. Each fact has fact_id, subject, predicate (the relation), object, statement, confidence, valid_from, valid_until, anchored_on, source_role and evidence
facts[].evidenceThe words behind the fact. observation_id and source_ordinal name the message; quote, byte_start and byte_end are the exact span; context is the text around it, and the quote starts at byte_start - context_start inside it; context_complete says whether context is the whole message
facts[].hops, path, viaFor a fact found one step further out: the names along the way and the relations between them
facts[].co_derived_withOther facts in this answer that came from the same sentence. They are one statement, not extra confirmation
anchors[]The entities your question matched: entity_id, name, type, and matched (the word that reached it)
reports, passagesTheme summaries and matching source text, when the deployment has them set up
truncatedThe size limit cut the answer. Facts aren't ranked, so what was cut is arbitrary
charactersHow much of the size limit the answer used
degradedParts of the search that didn't run, such as passages
reachHow the answer was found: terms, anchored, named_nothing_known and facts_per_anchor
controlsThe limits that were actually applied, defaults included
tip

An empty facts list can mean two things. If reach.named_nothing_known is true, memory has never heard of the names you used. If it is false, memory knows those names but has nothing that answers.

Treat what comes back as quotes, not instructions. Each fact is something somebody said once, and your agent should cite it rather than obey it. See the security model.

Check freshness

GET /v1/freshness tells you whether a question would see what you just saved.

curl -sS "$TAISCE_URL/v1/freshness" -H "Authorization: Bearer $TOKEN"
# {"scope":"default","stored":0,"formed":0,"parked":0}
import time

def wait_until_formed(offset: int, timeout: float) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
f = client.get("/v1/freshness").raise_for_status().json()
if f["formed"] is not None and f["formed"] >= offset:
return True
time.sleep(1)
return False
async function waitUntilFormed(offset: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const f = await call("GET", "/v1/freshness");
if (f.formed !== null && f.formed >= offset) return true;
await new Promise((r) => setTimeout(r, 1000));
}
return false;
}
type Freshness struct {
Scope string `json:"scope"`
Stored *int64 `json:"stored"`
Formed *int64 `json:"formed"`
Parked int `json:"parked"`
}

func waitUntilFormed(offset int64, timeout time.Duration) (bool, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var f Freshness
if err := call(http.MethodGet, "/v1/freshness", nil, &f); err != nil {
return false, err
}
if f.Formed != nil && *f.Formed >= offset {
return true, nil
}
time.Sleep(time.Second)
}
return false, nil
}

Response

FieldWhat it is
scopeYour project
storedThe highest log_offset stored. null until the first turn arrives
formedThe highest offset formed, with nothing unformed below it. null until the first turn forms
parkedHow many turns formation gave up on
rebuildingPresent only while an operator is re-reading the project's facts

Your turn is visible to questions once formed >= log_offset. Two things to watch:

  • A parked turn is one formation gave up on after failing several times. formed moves past it, so one bad turn can't hold up the whole project. If parked went up while you waited, your turn may be one of them. See turns are parked.
  • While rebuilding is present, answers mix the old and new readings of the project.

By task

Tool calls and groups

A tool call and the tool's result belong together, and roles alone can't say which result answers which call. So a turn with a tool message must group its messages with group_ordinal:

  • Give it on every message or on none. Without it, a tool message is refused.
  • The first message is group 0. Each next message stays in the same group or moves up by one. No gaps, and you can't go back to an earlier group.
  • A call and its results share one number.
{
"idempotency_key": "4b7f3c1e-5a0d-4f7e-9a61-0c2d8e3b9f10",
"data_subject_id": "alice",
"messages": [
{"group_ordinal": 0, "role": "user", "content": "What's the weather in Cork?"},
{"group_ordinal": 1, "role": "assistant", "content": "{\"tool\":\"weather\",\"city\":\"Cork\"}"},
{"group_ordinal": 1, "role": "tool", "content": "{\"sky\":\"overcast\"}"},
{"group_ordinal": 2, "role": "assistant", "content": "It's overcast in Cork."}
]
}

Get it wrong and you'll see 400 invalid_turn with a message such as group_ordinal must be supplied for every message or omitted for all or turns containing tool messages require explicit group ordinals.

Retries and idempotency keys

If a response gets lost, you can't tell whether the turn was stored. An idempotency key fixes that: make one random UUID per turn, and send the same key with the same body on every retry.

What you sendWhat happens
A new keyA new turn, 201
The same key and the same turn: the same person, messages, roles and orderThe original receipt again, 201. Nothing is stored or formed twice. The time a retry carries does not count, and the first stored time stands
The same key and a different turn409 idempotency_conflict
The same key after that turn was erased409 idempotency_conflict, from then on
No keyA new turn every time, even for identical words
A key that isn't a UUID400 invalid_turn

"The same body" means the same subject, the same messages in the same order, and the same occurred_at values. So build the body once and resend exactly that. Don't set occurred_at to "now" inside your retry loop. Timestamps you leave out are filled in after the comparison, so leaving them out is safe.

Keys belong to the project and stay reserved, even after an erasure. That way a retry queued before an erasure can't write the erased words back. Never reuse a key for new content.

Recall options

Add any of these to a POST /v1/recalls body:

FieldDefaultWhat it does
hops11 returns facts about what the question names. 2 also follows one more relation and shows the chain in path and via. Other values are clamped to 1 or 2
max_charactersthe server's limitA smaller size limit, counted in characters of fact text (not JSON, not tokens). Above the server's limit is 400 invalid_recall_controls
source_roles["user"]Whose words may supply facts: any of user, assistant, system, tool
surfacesall threeWhich parts answer: any of facts, reports, passages
themesfalseAlso include theme reports when the question matched entities
as_ofnowRFC 3339. What was true at that time
as_known_atnowRFC 3339. What memory knew at that time

The server's limit is TAISCE_BUNDLE_CHARACTERS, 16,000 characters unless the operator changes it.

note

source_roles defaults to ["user"]. Facts drawn from assistant, system or tool messages are left out unless you ask for them, because a web page your agent fetched is not your user speaking. Each fact says where it came from in source_role.

Reports and passages need an embedding model set up on the deployment. Without one, only facts are searched. If you list a surface in surfaces that can't run, the answer names it in degraded.

A person's history for a prompt

POST /v1/contexts gathers one person's history under a size limit. The newest turns come back word for word. Older turns come back as summaries that the background process has already written, oldest first. No model is called.

curl -sS -X POST "$TAISCE_URL/v1/contexts" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data_subject_id": "alice", "max_characters": 4000}'
ctx = client.post(
"/v1/contexts", json={"data_subject_id": "alice", "max_characters": 4000}
).raise_for_status().json()
summaries = [s["summary"] for s in ctx["segments"]]
recent = [m for t in ctx["turns"] for m in t["messages"]]
const ctx = await call("POST", "/v1/contexts", { data_subject_id: "alice", max_characters: 4000 });
const summaries = ctx.segments.map((s: any) => s.summary);
const recent = ctx.turns.flatMap((t: any) => t.messages);
type Context struct {
Segments []struct {
Summary string `json:"summary"`
} `json:"segments"`
Turns []struct {
LogOffset int64 `json:"log_offset"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
} `json:"turns"`
Truncated bool `json:"truncated"`
}

func history(subject string) (Context, error) {
var c Context
err := call(http.MethodPost, "/v1/contexts",
map[string]any{"data_subject_id": subject, "max_characters": 4000}, &c)
return c, err
}
Request fieldWhat it is
data_subject_idRequired. Whose history
max_charactersOptional. Same limit as recall
Response fieldWhat it is
segments[]Summaries of older turns: segment_id, level, from_offset, to_offset, covered, summary
turns[]Recent turns: log_offset, occurred_at, and messages (each with role and content)
watermarkFreshness at the time of the answer, shaped like GET /v1/freshness
characters, truncatedHow much of the limit was used, and whether it cut anything

Only turns that have formed appear. Without a subject you get 400 invalid_context.

Citations: the words behind a fact

Every fact from a question has a fact_id. POST /v1/citations/resolve turns it into the full record: its status, when it was true, and every quote behind it.

curl -sS -X POST "$TAISCE_URL/v1/citations/resolve" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"id": "<fact_id>"}'
citation = client.post("/v1/citations/resolve", json={"id": fact_id}).raise_for_status().json()
for ev in citation["evidence"]:
print(ev["role"], ev["quote"])
const citation = await call("POST", "/v1/citations/resolve", { id: factId });
for (const ev of citation.evidence) console.log(ev.role, ev.quote);
type Citation struct {
ID string `json:"id"`
Version string `json:"version"`
Status string `json:"status"`
Evidence []struct {
Role string `json:"role"`
Quote string `json:"quote"`
} `json:"evidence"`
}

func cite(factID string) (Citation, error) {
var c Citation
err := call(http.MethodPost, "/v1/citations/resolve", map[string]string{"id": factID}, &c)
return c, err
}
Request fieldWhat it is
idRequired. The fact_id
limitQuotes per page: default 8, up to 32
afterTo get the next page, send back the next value from the previous answer
Response fieldWhat it is
id, version, statusThe record, its current version (you need this to correct or retract it), and its status
predicate, statement, confidence, source_role, recorded_atWhat it claims, and whose words it came from
subject_entity_id, object_entity_idThe entities it links
valid, knownWhen it was true, and when memory knew it. Each has from, until, from_inclusive and until_inclusive
superseded_by, retractionPresent when the record was replaced or withdrawn
evidence[]Every quote behind it, each with source_observation_id, source_ordinal, log_offset, role, occurred_at, quote, byte_start, byte_end, context, context_byte_start, context_byte_end, context_complete and extractor_version
nextPresent when there are more quotes

Errors: 400 invalid_citation for a bad id or page, and 404 not_found for an id that is unknown, belongs to another project, or was erased; all three get the same answer on purpose. A source too large to cite gets 422 citation_limit; use an export for that one instead.

Reading a whole message. A passage points at its message with a chunk_id rather than quoting all of it. POST /v1/messages/get returns the message a window at a time:

Request fieldWhat it is
chunk_idRequired. From the passage
byte_startWhere to start. Default 0
byte_limitWindow size. Default and maximum 4,096 bytes
expected_digestRequired when byte_start is above 0: the content_digest from your first window

The answer has text, byte_start, byte_end, complete, next_byte_start, content_digest, message_bytes, role and occurred_at. Keep asking from next_byte_start until complete is true. If the message changed in between, you get 409 source_changed; start again from byte 0.

Inspecting records

A record is one stored fact. The fact_id you get from a question is a record id.

curl -sS -X POST "$TAISCE_URL/v1/records/list" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data_subject_id": "alice", "limit": 50}'
page = client.post("/v1/records/list", json={"data_subject_id": "alice", "limit": 50}).raise_for_status().json()
while True:
for rec in page["records"]:
print(rec["id"], rec["version"], rec["statement_preview"])
if "next" not in page:
break
page = client.post(
"/v1/records/list", json={"data_subject_id": "alice", "limit": 50, "after": page["next"]}
).raise_for_status().json()
let page = await call("POST", "/v1/records/list", { data_subject_id: "alice", limit: 50 });
for (;;) {
for (const rec of page.records) console.log(rec.id, rec.version, rec.statement_preview);
if (!page.next) break;
page = await call("POST", "/v1/records/list", { data_subject_id: "alice", limit: 50, after: page.next });
}
type RecordPage struct {
Records []struct {
ID string `json:"id"`
Version string `json:"version"`
StatementPreview string `json:"statement_preview"`
} `json:"records"`
Next *struct {
ID string `json:"id"`
} `json:"next"`
}

func listRecords(subject string) error {
req := map[string]any{"data_subject_id": subject, "limit": 50}
for {
var page RecordPage
if err := call(http.MethodPost, "/v1/records/list", req, &page); err != nil {
return err
}
for _, r := range page.Records {
fmt.Println(r.ID, r.Version, r.StatementPreview)
}
if page.Next == nil {
return nil
}
req["after"] = page.Next
}
}
CallSendGet back
POST /v1/records/listdata_subject_id (optional), limit (1–100, default 20), afterrecords[], each with id, version, predicate, statement_preview, statement_bytes, preview_truncated, source_role, recorded_at, status, subject_entity_id, object_entity_id; plus next
POST /v1/records/historyid, limit, beforeid, version, current, previous[], retraction, next
POST /v1/entities/listlimit, afterentities[] and next
POST /v1/entities/getidOne entity: id, identity_kind, canonical_name, aliases, first_seen_at

To page, send next back as after, or as before for history. A bad page or cursor gets 400 invalid_record_page (or invalid_entity_page).

Correcting records

You never edit history. You add a new statement with your name on it, and the old one stays available to inspect.

You want to...CallSend
Flag a record as wrong, changing nothing yetPOST /v1/feedback/recordrecord_id, note, and optionally proposed_object (the value it should have)
See flagged recordsPOST /v1/feedback/listOptional record_id, open_only, limit, after
Act on a flagPOST /v1/feedback/promoteid (the feedback) and expected_version (the record's). This becomes a correction if the feedback proposed a value, or a retraction if not
Replace a record's valuePOST /v1/records/correctrecords: 1 to 20 items, each with id, expected_version, object, statement, and optionally valid_from
Withdraw a recordPOST /v1/records/retractrecords: 1 to 20 items, each with id and expected_version
Add a fact your app knowsPOST /v1/records/assertrecords: items with idempotency_key, subject, predicate, object, statement, and optionally data_subject_id and valid_from
curl -sS -X POST "$TAISCE_URL/v1/records/correct" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"records": [{"id": "<record id>", "expected_version": "<version>",
"object": "Galway", "statement": "Alice lives in Galway."}]}'
fix = {"id": record_id, "expected_version": version,
"object": "Galway", "statement": "Alice lives in Galway."}
result = client.post("/v1/records/correct", json={"records": [fix]}).raise_for_status().json()
new_id = result["records"][0]["id"]
const result = await call("POST", "/v1/records/correct", {
records: [{ id: recordId, expected_version: version, object: "Galway", statement: "Alice lives in Galway." }],
});
const newId = result.records[0].id;
type Correction struct {
ID string `json:"id"`
ExpectedVersion string `json:"expected_version"`
Object string `json:"object"`
Statement string `json:"statement"`
}

type Corrected struct {
Records []struct {
OriginalID string `json:"original_id"`
ID string `json:"id"`
Version string `json:"version"`
} `json:"records"`
}

func correct(c Correction) (Corrected, error) {
var out Corrected
err := call(http.MethodPost, "/v1/records/correct", map[string]any{"records": []Correction{c}}, &out)
return out, err
}

Good to know:

  • expected_version is the version you read from a list, a history or a citation. If the record changed since, you get 409 record_conflict. Read it again and decide again.
  • A batch succeeds as a whole or not at all. A batch that touches too many quotes gets 413 record_mutation_limit; split it.
  • A correction returns original_id, id, version and source_observation_id for each item. An assertion returns id, version, source_observation_id and replayed.
  • An assertion's predicate must be one memory already knows. Each item carries its own key, so a retry returns the same record with replayed: true.
  • 409 feedback_already_promoted means someone already acted on that flag. Stop; don't retry.

Forgetting: erasure and export

POST /v1/erasures removes everything held about one person, or about a set of turns, and tells you what is left.

curl -sS -X POST "$TAISCE_URL/v1/erasures" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"data_subject_id": "alice", "reason": "user request"}'
receipt = client.post(
"/v1/erasures", json={"data_subject_id": "alice", "reason": "user request"}
).raise_for_status().json()
assert receipt["clean"], receipt["residual"]
const receipt = await call("POST", "/v1/erasures", { data_subject_id: "alice", reason: "user request" });
if (!receipt.clean) console.warn("left behind:", receipt.residual);
type Erasure struct {
RequestID string `json:"request_id"`
Deleted map[string]int `json:"deleted"`
Residual map[string]int `json:"residual"`
Clean bool `json:"clean"`
}

func erase(subject string) (Erasure, error) {
var e Erasure
err := call(http.MethodPost, "/v1/erasures",
map[string]string{"data_subject_id": subject, "reason": "user request"}, &e)
return e, err
}
Request fieldWhat it is
data_subject_idThe person to erase
source_observation_idsOr: up to 10,000 turn ids to erase, for turns saved without a subject. Never both
reasonOptional. Kept with the receipt
Response fieldWhat it is
request_id, scope, completed_atThe receipt
data_subject_id, source_observation_idsWhat you asked to erase
deletedHow many rows of each kind were removed
residualHow many rows still match, counted right after the delete
cleantrue when nothing is left

POST /v1/exports returns everything held, as sections of raw rows plus rows, a count per section you can compare with an erasure receipt. Name one of two things, as an erasure does:

  • data_subject_id — everything held about that person.
  • source_observation_ids — everything held about those turns, for a document observed project-wide, which has no person. At most 10,000 ids, and the response echoes them back.

An export is one answer at one instant, not a paged feed, so it is bounded: a person holding more than 50,000 rows gets 422 export_too_large, and you take their turns by source instead — the ids are in an ingest manifest, or in a recall's citations.

Good to know:

  • Read-only tokens can export but can't erase.
  • An erasure or export that names nobody gets 400 no_subject. Naming both a person and turns, or sending an id that isn't a UUID, gets 400 invalid_erasure for an erasure and 400 invalid_export for an export.
  • Running the same erasure twice is harmless.
  • A turn saved without a data_subject_id isn't reached by a person's erasure. Erase it by its id.

Other calls

CallWhat it's for
POST /v1/passages/searchFind source text by meaning: question, limit, candidates, data_subject_id, source_role. Needs an embedding model
POST /v1/entities/candidates, POST /v1/reports/candidatesFind entities or theme reports by meaning. Need an embedding model
POST /v1/subjects/register, /get, /list, /updateHave Taisce issue a stable random id to use as data_subject_id
POST /v1/artifacts/put, /get, /list, /deleteStore files your agent produces, within a per-project allowance
POST /v1/entities/purgeRemove an entity. Send confirm: true to act; without it you get a preview
POST /v1/notifications/endpoints/register, /list, /disable, POST /v1/notifications/deliveries/listBe told when memory forms, instead of polling

Notifications. Register an HTTPS URL, and each time formation moves forward Taisce sends it a signed message with the new offsets. The message never contains any content. Registering returns a secret, shown once. Check each delivery's Taisce-Signature header with it. Deliveries can repeat, so remember the highest offset you've handled. Notifications stay off until the operator sets TAISCE_NOTIFY_DESTINATIONS; until then every notification call answers 501 notifications_unavailable.

Errors

Every refusal has the same shape:

{"error": {"code": "invalid_turn", "message": "group_ordinal must be supplied for every message or omitted for all"}}

Branch on code. The message is for people reading logs, and its wording may change.

CodeStatusWhat it meansWhat to do
invalid_body400The JSON couldn't be read: unknown or misspelled field, duplicate key, bad timestamp, or over 1 MiBFix the request
invalid_turn400The turn broke a rule; the message says whichFix the turn. Don't resend it unchanged
invalid_question400Empty question, or too longShorten or split it
invalid_recall_controls400A recall option is out of rangeCheck max_characters, source_roles, surfaces
invalid_context400No subject, or max_characters out of rangeSend data_subject_id; lower the limit
no_subject400An erasure or export names nobodyName the person, or the turns
invalid_export400An export names both a person and turns, or too many turnsName one, at most 10,000 ids
export_too_large422One person holds more rows than an export returnsExport their turns by source
invalid_erasure400Both a person and turns, too many turns, or a turn id that isn't a UUIDSend one or the other
invalid_citation, invalid_record_page, invalid_entity_page, invalid_message_window, invalid_passage_query, invalid_entity_candidate_query, invalid_report_candidate_query400A bad id, cursor, window or search optionFix the request
invalid_record_mutation, invalid_feedback, invalid_subject, invalid_artifact400A bad change requestFix the request
invalid_destination400 or 409A notification URL the operator doesn't allow, or one already registeredUse an allowed HTTPS host
unauthenticated401Missing, wrong or revoked token, an operator token, or a suspended project. One answer for all, on purposeCheck the token. Don't retry
forbidden403A read-only token tried to change memoryUse a read-write token
not_found404Unknown, another project's, or erased. One answer for all, on purposeTreat it as gone
idempotency_conflict409The key already names a different or erased turnFix the client. Never retry
record_conflict, subject_conflict, artifact_conflict409Your expected_version is out of dateRead again, decide again
source_changed409The message changed between windowsStart again from byte 0
feedback_already_promoted409Someone already acted on this feedbackStop
record_mutation_limit413The batch touches too much evidenceSplit the batch
citation_limit422The source is too large to citeUse an export
rate_limited429Too busy right now, or too many turns waiting to form. Comes with Retry-After: 1Wait, then resend the same request with the same key
storage_capacity429The project's artifact allowance is full. No Retry-AfterDelete artifacts, or ask the operator. Waiting won't help
internal500Something failed on the serverRetry a few times if it's safe, then report it
notifications_unavailable501Notifications aren't turned onPoll freshness instead
no_database_capacity503The database had no free connection. Comes with Retry-After: 1Wait, then resend the same request with the same key
passage_unavailable, entity_candidates_unavailable, report_candidates_unavailable503No embedding model is set up or reachableA deployment setting. Don't retry in a loop

Writing a client

What is safe to retry

  • Reads change nothing. Retry them freely.
  • Saving a turn with a key. After a network error, a timeout, 429 rate_limited, 503 no_database_capacity or 500 internal, resend the same body with the same key. A turn saved without a key can't be retried safely.
  • Changes that carry expected_version, such as corrections, retractions, subject updates and artifacts. If you don't know whether it worked, read the record again and decide again. Resending with a freshly read version could apply the change twice.
  • Feedback has no key and no version. If you're unsure it landed, list the record's feedback before sending it again.
  • 409 idempotency_conflict is never retryable. Minting a new key for the same words after an erasure would write back what someone asked to remove.

Use Retry-After as the shortest wait, make each wait longer with a little randomness, and stop after a few tries. How many tries and how long to wait is up to you.

# Build the body once, then resend exactly those bytes.
BODY='{"idempotency_key":"'"$(uuidgen)"'","data_subject_id":"alice","messages":[{"role":"user","content":"I live in Cork now."}]}'
OUT=$(mktemp)
for attempt in 1 2 3 4 5; do
status=$(curl -sS -o "$OUT" -w '%{http_code}' -X POST "$TAISCE_URL/v1/observations" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$BODY")
case "$status" in
201) cat "$OUT"; break ;;
429|500|503|000) sleep $((attempt * attempt)) ;; # 000: no response at all
*) cat "$OUT"; break ;;
esac
done
import random
import time

RETRYABLE = {"rate_limited", "no_database_capacity", "internal"}

def save_with_retries(turn: dict, attempts: int = 5) -> dict:
"""Send the same turn, with the same idempotency_key, until it is stored."""
delay = 1.0
for attempt in range(1, attempts + 1):
try:
r = client.post("/v1/observations", json=turn)
except httpx.TransportError: # network failure or timeout
if attempt == attempts:
raise
else:
if r.status_code == 201:
return r.json()
code = r.json().get("error", {}).get("code")
if code not in RETRYABLE or attempt == attempts:
r.raise_for_status()
delay = max(delay, float(r.headers.get("Retry-After", "1")))
time.sleep(delay + random.uniform(0, delay))
delay *= 2
raise RuntimeError("unreachable")
const RETRYABLE = new Set(["rate_limited", "no_database_capacity", "internal"]);

// Send the same turn, with the same idempotency_key, until it is stored.
async function saveWithRetries(turn: object, attempts = 5) {
let delay = 1000;
for (let attempt = 1; ; attempt++) {
try {
return await call("POST", "/v1/observations", turn);
} catch (err) {
const network = err instanceof TypeError || (err as Error).name === "TimeoutError";
const retry = err instanceof Refusal ? RETRYABLE.has(err.code) : network;
if (!retry || attempt === attempts) throw err;
if (err instanceof Refusal && err.retryAfter) delay = Math.max(delay, err.retryAfter * 1000);
}
await new Promise((r) => setTimeout(r, delay + Math.random() * delay));
delay *= 2;
}
}
// Also import "errors" and "math/rand/v2".

var retryable = map[string]bool{"rate_limited": true, "no_database_capacity": true, "internal": true}

// saveWithRetries sends the same turn, with the same idempotency key, until it is stored.
func saveWithRetries(turn Turn, attempts int) (Receipt, error) {
delay := time.Second
for attempt := 1; ; attempt++ {
var receipt Receipt
err := call(http.MethodPost, "/v1/observations", turn, &receipt)
if err == nil {
return receipt, nil
}
var refusal *Refusal
if errors.As(err, &refusal) {
if !retryable[refusal.Code] {
return Receipt{}, err
}
delay = max(delay, refusal.RetryAfter)
}
if attempt == attempts {
return Receipt{}, err
}
time.Sleep(delay + rand.N(delay)) // add some randomness so clients don't retry in step
delay *= 2
}
}

One request at a time per token

Each API process accepts only a few requests at once, split per project and then per token. Extra requests are refused straight away with 429 rate_limited; they don't wait in a queue. With the compose file's default settings, that means one request in flight per token on each API process. So send calls one at a time per token. If you need more, ask the operator to raise TAISCE_MEMORY_POOL. Adding more tokens for the same project won't help, because the project's share is the limit.

Knowing when memory has formed

Formation happens after the save, so an agent that saves and immediately asks can miss its own turn. Either keep the log_offset from the receipt and poll freshness until formed reaches it, or register for notifications and be told.

Going deeper