Limits and behaviour
What is asynchronous, how fast retrieval actually is, what the vectors are, and what is not enforced yet.
This page is the one that saves you an afternoon. It documents what GitLoom actually does today, including the parts that are not finished. Where a number is measured, it says so and against what. Where something is not enforced yet, it says that too, rather than describing an intention as a behaviour.
Writes are asynchronous
POST /v1/memories returns 202 Accepted. The memory does not exist yet.
{"id":"3f2b9e01-7c44-4d1a-9f6e-0a1b2c3d4e5f","namespace":"user-8213","status":"accepted"}
202 rather than 200 because extraction is several model calls against a
conversation. Claiming 200 would make the next read look broken.
What happens between the 202 and the memory existing:
- The request is validated — credential, namespace exists, at least one message — and enqueued. This is where a rejection happens, while you are still listening.
- A worker picks it up, fetches the namespace's repository, and runs extraction, cue-writing, relation-linking, and reconciliation.
- New and rewritten memories are committed to git, embedded, packed, and pushed back to object storage with a compare-and-swap.
- The namespace's recorded head advances. From that instant, retrieval returns the new memories.
How long that takes
There is no published SLA, and you should not build a UI that depends on a specific figure. What is measurable:
- A single extraction call against a one-line conversation completed in 1.3s through the Bedrock adapter.
- A realistic session runs four prompt stages, some of which fan out across the extracted facts. Expect seconds for a short conversation, tens of seconds for a long one.
- The worker's hard timeout is 15 minutes. Anything approaching that is a bug, not a slow day.
There is no way to poll for completion
This is the sharpest edge in the current API, so it is worth stating plainly:
- The
idin the202is the queue message id. It is not a memory id, and there is no endpoint that accepts it. - There is no
GET /v1/memories/{id}, no job status route, and no webhook. - The only observable signal that ingestion landed is that
GET /v1/retrievestarts returning the new material.
What to do instead. Do not write-then-immediately-read in the same user turn and
expect the memory back. Write at the end of a turn and retrieve at the start of the
next one; by then, ingestion has almost always finished. If you genuinely need to
confirm, poll /v1/retrieve with a query you know the new content should match, with
a backoff, and give up gracefully — retrieval is cheap, but it is not free.
Ordering and duplicate suppression
- Writes to one namespace are serialised. The queue's message group is the
account/namespacepair, so one write to a given memory runs at a time. That is required for correctness: reconciliation decides whether a new fact rewrites an existing file, so two concurrent writers rebuilding the same repository would discard one another's work wholesale rather than merge it. - Different namespaces run in parallel, bounded by worker concurrency (below).
- Identical bodies within a five-minute window are deduplicated. The queue is
content-addressed for deduplication, so resending the exact same request body twice
in quick succession returns
202both times and ingests once. This absorbs an at-least-once caller; it also means a genuine retry of an identical payload is silently a no-op. Varysession_idif you mean two distinct writes. - Failed messages are retried up to three times, then moved to a dead-letter queue where they are held for 14 days and alarmed on. A message reaching the DLQ is a lost write, and it is treated as a correctness alarm rather than a backlog one.
A partial failure that is not visible to you
If extraction succeeds but embedding fails, the memories are committed to git anyway and the failure is logged. Those memories are retrievable immediately by the lexical and graph arms, but not by semantic search, until the next successful write to that namespace fills the missing vectors in. Failing the whole job instead would re-run — and re-charge for — extraction that already worked.
The practical symptom: a memory that a keyword query finds but a paraphrased question does not, which starts working after the user's next session is ingested.
Retrieval latency
GET /v1/retrieve makes no model call except the query embedding.
Measured against the deployed stack over 200 authenticated requests:
| p50 | p99 | max | |
|---|---|---|---|
Server-side retrieval (RetrievalMillis) |
109 ms | 145 ms | 152 ms |
Client end-to-end, India → ap-south-1 |
211 ms | 383 ms | 765 ms |
Read that table carefully, because the two rows mean different things:
- 109 / 145 ms is server-side: the time inside the retrieval function. It is the number the internal p99 alarm watches, against a 200 ms budget.
- 211 / 383 ms is what a client actually waits. The difference is network. If you
are further from
ap-south-1than India is, your number is larger, and no amount of work on our side changes that. GitLoom runs in one region today.
Roughly 100 ms of the server-side figure is the query embedding — a network round trip to Bedrock, not search. For comparison, before an embedder was wired the same measurement was p50 7.4 ms / p99 19.6 ms. So:
- Search itself costs single-digit milliseconds.
- The p99 budget is now spent almost entirely on one round trip, and the margin is 1.4x rather than the tenfold it used to be.
- The lexical and semantic arms run concurrently, so the embedding is not additive with lexical time — it dominates it.
vector_ms, lexical_ms and graph_ms are returned on every response so you can see
this yourself rather than take our word for it.
Cold starts and cache behaviour
cold_start: truemarks the first request served by a fresh container. A cold start has been observed at 167 ms of function duration versus a 9 ms warm p50, plus the cost of fetching the namespace's repository.- A container keeps up to four namespaces warm on local disk, evicting the least recently used. A fifth namespace on the same container pays a fetch.
- Freshness is not a guess: every request does a metadata read and compares the warm copy's commit against the namespace's recorded head. A stale copy is discarded. So once ingestion records a write, the next read sees it — there is no propagation window to design around, and a deletion cannot linger in a warm cache.
Retrieval defaults
limit default |
24 hits |
| Graph expansion | 1 hop, from the top 6 hits |
Expired incidents |
excluded |
| Empty result | hits is absent, not [] |
| Namespace listing | first 200 namespaces |
Embeddings
| Model | Amazon Titan Text Embeddings v2 (amazon.titan-embed-text-v2:0) |
| Dimensions | 1024 |
| Normalised | yes |
| Symmetric | yes — queries and stored text are embedded identically |
| What is embedded | cues, not memory bodies |
| Region | ap-south-1, via Bedrock |
Why Titan and not Cohere, which would batch better: Cohere on Bedrock is a Marketplace
model requiring an account-level subscription accepted in the console — IAM permissions
alone do not unlock it. Titan is Amazon-owned and works with nothing but
bedrock:InvokeModel. Both produce 1024 dimensions, so the stored vector width does
not change if that decision is revisited.
Two consequences worth knowing:
- Your text is sent to Amazon Bedrock in
ap-south-1, for both embedding and extraction. Extraction uses Claude Haiku 4.5 through Bedrock's Converse API. If your data cannot leave a boundary that excludes this, GitLoom's hosted service is not the right deployment. - Semantic search scans linearly in the number of stored vectors. The published latency figures were measured on a 240-memory fixture, and they do not extrapolate to a very large namespace on their own. One namespace per end user keeps each index small, which is a large part of why the model is shaped that way.
Quotas
Per-account quotas are enforced. Every account has a plan — free if nothing was
ever chosen — and every write, retrieval, playground chat, and stored-memory count is
metered against that plan's monthly limits. See Plans and pricing
for the numbers themselves; this section is about the mechanics.
- The check is atomic, a single conditional
UpdateItemrather than read-then-write — two concurrent requests both reading "199 of 200 used" cannot both believe they are within budget. - Refused before anything downstream runs. A write that will be refused is refused
before it is queued; the caller never holds a
202for something that never happens. - Free and pay-as-you-go stop at the limit. Exceeding a meter returns
429 quota_exceededfor the rest of the month. - Subscribed plans (Starter and above) overflow into the wallet instead of
stopping — see Overage. An empty wallet on overage
is
402 balance_exhausted. - Every plan also has a per-minute rate limit, independent of the monthly
allowance — see Rate limits. A throttled request
is
429 rate_limitedand costs nothing; it clears within the minute rather than at the next calendar month. - Read your own standing with
GET /v1/usage, and the wallet/plan/rate card withGET /v1/billing— see API reference.
{"error":{"code":"quota_exceeded","message":"quota: limit reached: writes (100 per month)"}}
{"error":{"code":"balance_exhausted","message":"quota: balance exhausted: reads allowance spent and the wallet cannot cover the overage"}}
{"error":{"code":"rate_limited","message":"quota: rate limited: the free plan allows 30 reads per minute"}}
A notification fires internally when a meter crosses 80% of its limit, so an account approaching a ceiling is not surprised by it.
Infrastructure ceilings, shared across every account
Separate from per-account quotas, these are platform-wide limits — they exist so no single account's traffic can starve every other account's, and they are not sized per plan:
| limit | value | what you see when you hit it |
|---|---|---|
| Concurrent retrievals, whole platform | 20 | Lambda throttles; the gateway returns 5xx. Retry with backoff. |
| Concurrent ingestion workers, whole platform | 5 | Nothing — the queue absorbs it. Your write takes longer to land. |
| Ingestion worker timeout | 15 min | The message is retried, then dead-lettered. |
| Queue retention | 4 days | A message not processed in 4 days is dropped. |
| Namespace name | [a-z0-9-], ≤64 chars |
400 invalid_namespace |
Namespaces returned by GET /v1/namespaces |
200 | The list is truncated silently. |
| Credential cache at the gateway | 30 s | See below. |
| Recharge, one transaction | ₹500 min, ₹1,00,000 max | 400 below_minimum / 400 above_maximum |
Revocation takes up to 30 seconds. Authorization results are cached for 30 seconds so that an agent calling with the same key every turn does not pay a lookup each time. A revoked key therefore keeps working for up to half a minute. Treat revocation as "stops within a minute", not "stops instantly", and rotate anything genuinely compromised with that in mind.
Errors
The error body has two shapes, which is an inconsistency you have to code around today:
Everything except retrieval:
{"error":{"code":"namespace_not_found","message":"create namespace \"user-8213\" before writing to it"}}
GET /v1/retrieve:
{"error":"namespace not found","namespace":"user-8213"}
Branch on code where it exists and on the HTTP status everywhere. The message text
is prose and may be reworded; the codes are stable.
| status | code | meaning |
|---|---|---|
400 |
invalid_body |
body missing or not valid JSON |
400 |
invalid_namespace |
namespace name outside [a-z0-9-]{1,64} |
400 |
no_messages |
messages was empty |
400 |
invalid_env |
key env was not live or test |
401 |
— | missing, malformed, expired or revoked credential |
403 |
dashboard_only |
key management attempted with an API key |
404 |
namespace_not_found |
write to a namespace that was never created |
404 |
not_found |
no such key, or no such route |
500 |
internal |
logged our side; safe to retry |
A 401 is deliberately uninformative. Distinguishing "no such key" from "wrong
secret" tells an attacker which half of a guess was right, so both fail identically —
and in constant time.
Environments: live and test are not isolated
A key carries an environment in its prefix, gl_live_ or gl_test_, and
GET /v1/whoami reports it. The environment is not currently a data boundary. A
gl_test_ key reads and writes exactly the same namespaces as a gl_live_ key on the
same account.
The prefix is useful today for two real things: a production credential pasted into a test config is obvious on sight, and secret scanners can match it. It is not a sandbox. If you need one, use a separate namespace — that is a hard boundary — or a separate account.
Browsers
The API's CORS policy allows GET and OPTIONS only, from the dashboard's
origins. A browser cannot POST /v1/memories, POST /v1/namespaces, or manage keys.
That is a limitation, but the security advice would be identical without it: an API key is account-wide read and write, so it belongs on a server. Call GitLoom from your backend, and let your backend decide what a browser may ask for.
What GitLoom does not do yet
Listed so you can plan around them rather than discover them:
- No delete. There is no endpoint to remove a memory or a namespace. Nothing in the API satisfies a GDPR erasure request today; that has to be done by an operator.
- No update or direct write. You cannot author a memory yourself. Everything goes through conversation ingestion.
- No listing or reading by path.
GET /v1/retrieveis the only way to see stored content, and it returns a 240-character snippet rather than the full file.GET /v1/graphshows the tree and its edges, but titles and hashes only — not bodies. - No pagination anywhere. Listings are capped and truncated silently.
- One region.
ap-south-1. There is no data-residency option. - No webhooks or events.
Reproducing these numbers
The latency figures come from the deployed stack, driven over 200–250 authenticated
requests against a 240-memory fixture, and read back from the same
Gitloom/Retrieval metrics the production p99 alarm watches. Ingestion timings come
from the worker's own logs. Nothing on this page is an estimate presented as a
measurement; where a figure is a range rather than a number, it is written as one.