HTTP API
The API contract
Everything the client does is a plain HTTP call you can make yourself. This page is the contract: how you authenticate, what comes back, what the failures mean, and which endpoint does what.
Base URL
Every path below is relative to this origin. Treat the host as a variable — it will move to a custom domain — and keep it in one place in your configuration rather than spread through your code.
https://pfmem-api.packagefactory.dkTwo ways to authenticate
A bearer API key
Everything else. The key resolves server-side to an account, a workspace, a role and a set of verbs. Never put it in a query string, in an error report, or in anything that records a URL.
authorization: Bearer mk_live_...A session cookie
Used by the sign-up and sign-in routes, and by the two account routes — the one that mints an account's first master key, and the one that hands a browser its CSRF token. This is the browser's mechanism, and it is the only way to reach the endpoint that issues the first key, because before that call there is no key to authenticate with.
The CSRF token
A state-changing request authenticated by a cookie must echo this session's token in a header, because a browser attaches a cookie to a cross-site request on its own. A caller using an API key needs none of this: a browser never attaches a key by itself, so a key cannot be used cross-site.
GET /api/v2/account/csrf-token → { "csrf_token": "...", "header_name": "x-csrf-token" }
POST /api/v2/account/master-key → x-csrf-token: <that token>The envelope
Every endpoint answers in one of two shapes. Success carries a request id and a data object; failure carries an error with a stable code, a human message and a type.
{
"request_id": "23ba77f0c6474ccb8dc5d65d1a44d069",
"data": { }
}{
"error": {
"code": "version_conflict",
"message": "…",
"type": "invalid_request_error"
}
}The API never returns 200 on an error. If the status is 200 there is a data object, and if there is an error object the status says so too.
Branch on the code, never on the message. Never show a raw code to a person — map it to copy they can act on.
Put the request id somewhere copyable on any error screen. It is the first thing support will ask for, and the service logs the same value.
Status codes
Five codes cover every failure. Only two of them are worth retrying: a retry on the other three sends the same rejected request again.
| Code | Meaning | How to handle it |
|---|---|---|
401 | Missing or invalid key. | Ask for a key, or sign in again. Never retry. |
403 | The key lacks the verb, or the target is out of its scope. | Name the missing permission. Never retry. |
422 | The request failed validation. | Show the error against the field it belongs to. Never retry. |
429 | A quota or rate limit was reached. | Show the limit and when it resets. Retry after backing off. |
503 | Something MemoryAgent depends on is unavailable. The request was not processed; retry it. | Transient. Retry with backoff. |
Key tiers
The prefix tells you what a key can reach before you use it. A list of keys never returns a secret — only a prefix, a label and a date — because a secret is shown once, at the moment it is minted.
| Prefix | Tier | Scope | Verbs |
|---|---|---|---|
mk_live_ | master | Every workspace in the account. | read · write · delete |
tk_live_ | tenant | One workspace. | read · write · delete |
rk_live_ | tenant_role | One workspace, one role. | A reader may read; a writer may read and write. |
Roles
A role key carries a role, and the role decides what the key may reach inside its workspace. Pick the narrowest one that still does the job: a key is where a mistake is contained. Four of the six are tied to one person or agent, and a key with one of those is minted with the subject it acts for.
| Role | Reads | Writes | Names a subject |
|---|---|---|---|
admin | The whole workspace. | Anything, and it is the only role that may delete. | No |
writer | The whole workspace. | As one named person. | Yes |
reader | The whole workspace. | Nothing. Use it for dashboards and reports. | No |
agent | Only what this agent wrote itself. | As itself. Use it when one agent must not see another's work. | Yes |
shared-agent | The whole workspace. | As itself. Use it for a team of agents sharing one case while each stays attributable. | Yes |
restricted-user | Only that one person's memories. | As that person. | Yes |
Memory
The surface your application calls, authenticated by a bearer key. Ingestion is asynchronous, so a memory that is still being processed is a real state in your interface rather than an edge case.
| Endpoint | Purpose |
|---|---|
POST /api/v2/memory/add | Store a conversation. Returns a task id; extraction runs behind it. |
POST /api/v2/memory/search | Ranks results by combining meaning-based and exact-wording matches. |
POST /api/v2/memory/get | List a subject's memories, filtered and paginated. |
POST /api/v2/memory/recall | Assemble a prompt-ready block: the persona, the scenarios and the top memories, under a token budget. |
POST /api/v2/memory/edit | Amend one stored memory. It is re-indexed, so later searches match the new wording. |
POST /api/v2/memory/delete | Delete extracted memories. The underlying conversation is retained. |
POST /api/v2/memory/flush | Run a session's pending extraction now. |
POST /api/v2/memory/trail/list | Read the workspace's change trail: every recorded change to its memories, newest first, hash-chained. |
POST /api/v2/memory/procedures/* | Procedural memory: write, read, version, retire, list and search the procedures a workspace keeps. Its own section is below. |
POST /api/v2/me | Report what the key you are holding resolves to: its workspace, tier, role, verbs, and the subject it is bound to, if any. |
What a request and a reply look like
The lists above say what each call is for. These say what you send and what comes back, for the write every client makes first and the read it makes next.
Storing is asynchronous: the reply is a task, not a memory. The batch is extracted in the background, and the id you supply makes a repeat of the same call safe.
{
"mode": "chat",
"session_id": "s_17",
"user_id": "[email protected]",
"idempotency_key": "add-s17-0001",
"messages": [
{ "role": "user", "content": "I always take the night train to Berlin." },
{ "role": "assistant", "content": "Noted." }
]
}{
"request_id": "23ba77f0c6474ccb8dc5d65d1a44d069",
"data": {
"task_id": "b80658f8-c2f4-4e1b-8a98-c2bc42189e02",
"status": "queued",
"accepted_messages": 2,
"idempotent_replay": false,
"created_at": 1787475991675
}
}A search returns ranked hits. The memory itself is on the record inside each hit, not on the hit — and the score is a fused position rather than a confidence, so read the content to judge relevance rather than thresholding on the number.
{
"memory_type": "user",
"user_id": "[email protected]",
"query": "how do they prefer to travel",
"top_k": 5
}{
"request_id": "9130cb814f654e1ea042497d6362b08d",
"data": {
"hits": [
{
"record": {
"id": "a1b2c3",
"memory_type": "user",
"subject_id": "[email protected]",
"session_id": "s_17",
"category": "preference",
"content": "Prefers the night train to Berlin.",
"confidence": 0.82,
"version": 1,
"created_at": 1787475991675,
"updated_at": 1787475991675
},
"score": 0.0328,
"rank": 1,
"vector_rank": 1,
"lexical_rank": 1
}
],
"method": "hybrid"
}
}Every field of every call, with its exact type, permitted values and defaults, is published as a machine-readable contract. It is generated from the very definitions the server validates against, so it cannot describe a field the server does not enforce. Point a client generator at it.
https://pfmem-api.packagefactory.dk/openapi.jsonWhat is trusted, and what is not
Every item carries two answers about trust, and they are different questions. One is whether a person vouched for it. The other is whether it reaches the caller who is asking. A read authenticated with a key is marked in its content and counted in its response; a read from a signed-in session sees everything, badge and all.
| Field | Values | What it means |
|---|---|---|
verification | verifiedunverifiedrejected | Whether a person approved it. Only a signed-in person can, so a key writes the unapproved value; the rejected value is a person's report that it is wrong, and it is never served — neither a read nor an edit by a key can bring it back, only a person editing the text on the web. An atom is approved only if every conversation turn it cites is. |
availability | servedwithheldquarantined | Whether this caller is served the item at all. The served value reaches them; the withheld one does not, and is reported only as a count; the quarantined one was taken out of circulation by a report, so no key is served it while a signed-in session still sees it in the dashboard. |
withheld_count | a whole number | How many items this read left out because they are withheld from this caller, counted over the whole selection rather than over the page. Always 0 for a session, which is served those items itself. |
pending_version | an object, or absent | On a procedure read: the newer version a key has written that nobody has approved, named when the read is served the older approved version. Absent when the head itself is what is served, which is the case until some version has been approved. |
The marker in front of unapproved content
[unverified]
A read authenticated with a key is given unapproved content with this prefix in front of it, so a model reading it is told that nobody has approved it. The prefix is added to the answer and never stored: the item keeps its own text, and a person reading the same item sees no prefix.
{
"request_id": "9130cb814f654e1ea042497d6362b08d",
"data": {
"hits": [
{
"record": {
"id": "a1b2c3",
"content": "[unverified] Prefers the night train to Berlin.",
"verification": "unverified",
"availability": "served"
},
"score": 0.0328,
"rank": 1
}
],
"method": "hybrid",
"withheld_count": 2
}
}The count of what you did not receive
A listing, a search and a recall all report how many items they left out rather than quietly answering with fewer. The count is a fact about the caller's whole selection — the subject, the corpus and the credential, not the query and not the filters this request added — so it cannot be narrowed by asking again. No text, id or name from a withheld item reaches the response.
A procedure serves the approved version while a newer one waits
A procedure is a way of working, so once a person has approved a version, a newer one a key writes is not served to agents: the read answers with the approved version and names the newer one as pending. Where no version has been approved yet, the newest version is served with the unverified marker instead — a runbook that only ever a key wrote is usable, and only a newer key version behind an approved one is held back.
pending_version
Flagged for review
The page under Review in the dashboard is where a person sees what was held back and answers it. Approving there is the only thing in the product that makes an item trusted: nothing a key sends sets the trust answer, and nothing a key sends makes an item reachable.
Procedures
A procedure is a how-to your agents keep: the steps for a release, a triage runbook, the way this workspace actually handles a refund. A memory is a fact extraction found for you; a procedure is a document you write on purpose, and it is versioned from the moment it is created.
| Endpoint | Purpose |
|---|---|
POST /api/v2/memory/procedures/create | Write a new procedure. It comes back at version 1. |
POST /api/v2/memory/procedures/get | Read one procedure, its text included — retired or not. |
POST /api/v2/memory/procedures/update | Append a new version. Every version before it stays readable. |
POST /api/v2/memory/procedures/archive | Retire a procedure. It leaves listing and search, and nothing is destroyed. |
POST /api/v2/memory/procedures/list | The procedures this key can see, most recently changed first. |
POST /api/v2/memory/procedures/search | Match procedures by wording, with the matching passage as an excerpt. |
POST /api/v2/memory/procedures/versions | The version history behind the current one, newest first. |
Who can see one
Visibility decides who can see a procedure. Private means the agent that owns it, plus any key that reads the whole workspace. Team means everyone in that workspace. A create that names neither is private, because sharing is a decision rather than a default. Neither value reaches outside it, and nothing a request can send widens either. It is chosen when the procedure is created, and an update may move it between the two.
"visibility": "private""visibility": "team"
A key bound to no agent cannot create a private procedure — private means private to one agent, so such a key would be storing something it could never read back. The request is refused with a validation error naming the value to use instead.
{
"name": "nightly-release",
"description": "how we ship at night",
"content": "Freeze at six, run the checks, ship only when green.",
"visibility": "team",
"reason": "first written down"
}{
"request_id": "4c7f1d02a1a34a2f9a3f0b1c6d5e8f70",
"data": {
"procedure": {
"procedure_id": "pr_9f3c1a2e-5d47-4b8a-9c31-6e0d2f7b4a15",
"name": "nightly-release",
"description": "how we ship at night",
"kind": "procedure",
"scope": "workspace",
"workspaces": null,
"origin": "agent",
"agents_can_edit": false,
"version": 1,
"status": "active",
"visibility": "team",
"owner_agent_id": "release-bot",
"user_id": null,
"created_at": 1787475991675,
"updated_at": 1787475991675,
"content": "Freeze at six, run the checks, ship only when green.",
"includes": []
}
}
}Who can change one
For the two credentials that read only their own memories, this surface is the wider one: a key bound to one agent reads only the memories it wrote but reads its own procedures and every shared one, and a key bound to one person reads the shared ones — a runbook nobody but its author can read is no use to anybody. It runs the other way for the two that already read every memory in the workspace while writing under their own name: the one bound to an agent reads the shared procedures plus the ones it wrote itself, the one bound to a person reads the shared ones, and neither reaches another agent's private procedure. Every credential bound to no subject reads the workspace whole.
Seeing a procedure and writing over it are separate questions, and the write is the narrower one. A new version is appended, and a procedure retired, by the subject that owns it — the agent it was created under, or the person who created it when no agent owns it — or by a key bound to no subject at all, which answers for the whole workspace. A credential that spans a whole account reads this surface and does not write it: writing means naming the workspace you are writing in, which is what a workspace key already does.
To everyone else the procedure answers exactly as one that does not exist. That is deliberate: a refusal saying “not yours” would confirm it is there, so a procedure you can read but not change refuses in the same words a wrong id gets. Its version history answers the same way.
Versions, and retiring one
An update never edits in place. It appends a new current version and keeps every one before it, each stamped with who wrote it, so an old runbook is never lost under a new one. Send the version you read and the append is refused rather than overwriting work you never saw. Retiring a procedure takes it out of listing and search while the current text and the whole history stay readable to anyone holding its id, and nothing on this surface deletes. Retiring is one-way: a retired procedure takes no further version and nothing brings it back, so a way of working that changes after it is retired is a new procedure with a new id.
{
"procedure_id": "pr_9f3c1a2e-5d47-4b8a-9c31-6e0d2f7b4a15",
"content": "Freeze at seven now.",
"expected_version": 1,
"reason": "the freeze moved an hour"
}Finding one
Search and listing answer with summaries — the name, the description, the current version, its state, its visibility and its owner — and search adds the passage that matched, with the matching words marked, so strip the marks or escape the passage before you show it. A search reads the name, the description and the opening of the content, so a distinctive description is what makes a long procedure findable. The text itself comes back from the read, and from each write. A search here is a retrieval and counts against the same allowance a memory search does.
{
"query": "release freeze",
"top_k": 5
}{
"request_id": "b0f9a71c5e2d4d15b7a0c9e3f4218ad6",
"data": {
"hits": [
{
"procedure": {
"procedure_id": "pr_9f3c1a2e-5d47-4b8a-9c31-6e0d2f7b4a15",
"name": "nightly-release",
"description": "how we ship at night",
"version": 2,
"status": "active",
"visibility": "team",
"owner_agent_id": "release-bot",
"user_id": null,
"created_at": 1787475991675,
"updated_at": 1787648791675
},
"score": 0.41,
"excerpt": "<mark>Freeze</mark> at seven now."
}
]
}
}An export of a workspace carries them: every procedure, and every version of every procedure.
Tasks
The only two GETs in the memory API. Statuses are queued, running, succeeded, failed and cancelled; the last three are terminal, so stop polling when you reach one.
| Endpoint | Purpose |
|---|---|
GET /api/v2/tasks | List background jobs. |
GET /api/v2/tasks/{task_id} | One job's status and progress. |
Administration
Master key only. This is where workspaces and keys are managed, and it is deliberately outside the command-line client's surface.
| Endpoint | Purpose |
|---|---|
POST /api/v2/admin/tenants | Create a workspace. |
POST /api/v2/admin/tenants/list | List the account's workspaces. |
POST /api/v2/admin/tenants/suspend | Suspend a workspace. Reversible. |
POST /api/v2/admin/tenants/resume | Resume a suspended workspace. |
POST /api/v2/admin/tenants/erase | Erase a workspace. Irreversible — everything in it is removed outright. |
POST /api/v2/admin/tenants/export | Export everything held for one workspace, in one document. |
POST /api/v2/admin/tenants/embedding-version | Move a workspace to another embedding version. The re-embed runs as a task. |
POST /api/v2/admin/embedding-versions/list | List the registered embedding versions a workspace may use. |
POST /api/v2/admin/audit/list | Read the account's audit trail: keys, workspaces, credentials and plan changes. |
POST /api/v2/admin/keys | Mint a key. |
POST /api/v2/admin/keys/list | List keys. Never returns a secret. |
POST /api/v2/admin/keys/revoke | Revoke a key. |
POST /api/v2/admin/keys/rotate | Rotate a key and issue its replacement. |
POST /api/v2/admin/credentials | Store your own provider key, encrypted. |
The release channel
Five public routes with no authentication, serving the installer and the published artifacts. They are absent from the OpenAPI document on purpose: that document is the memory contract your application calls, and a shell script and a download are infrastructure.
| Endpoint | Returns |
|---|---|
GET /install.sh | The installer, stamped with the origin it was fetched from. |
GET /cli/latest | The published version, its sha256 and its size. |
GET /cli/download/:version | The client bundle. |
GET /cli/skill/latest | The published skill version, its sha256 and its size. |
GET /cli/skill/download/:version | The skill body. |
Conventions worth knowing before you write a client
- Nearly everything is POST.
- Reads included. A read takes a body of filters, and a key belongs in a header rather than in a URL that proxies and browser history will record. The two task endpoints are the only GETs. Do not treat this as an oversight to correct.
- Timestamps are unix milliseconds.
- Thirteen digits, at or above 1000000000000, in both directions. A seconds-precision value is rejected rather than read as a date in 1970.
- A read names at most one subject.
- The listing and the search each take a single subject identifier, and it must match the memory type: a user query names a user, an agent query names an agent. Naming none is allowed and means every subject of that type the key can already reach. Sending both is the one combination that fails validation, so build the choice as a toggle with an everyone position rather than two fields. A write is stricter: storing a memory names exactly one subject, because it has to belong to somebody.
- Bodies are validated strictly.
- An unrecognised field is a validation error, not something quietly ignored. A misspelt name fails loudly instead of appearing to work — and no field you could send names a workspace, a filter or an embedding version in the first place.