Quickstart
From nothing to a retrieved memory in five requests.
GitLoom stores what your agent learns about a user as markdown in a git repository, and gives it back when a later question needs it. This page takes you from nothing to a retrieved memory.
The API is HTTPS and JSON, so every example below is plain curl or the fetch
built into Node 20+. There is also an SDK, if you would rather not write the
requests yourself:
npm install @gitloomhq/sdk # current: 0.10.0
See the TypeScript SDK page once you are past this page — it wraps everything below in a few lines.
0. Set the base URL
export GITLOOM_API="https://api.gitloom.cloud"
Check it without any credential:curl -s "$GITLOOM_API/health"
{"status":"ok"}
/health is the only unauthenticated route. Everything else answers 401 without a
credential and 403 with one it refuses. The gateway sends both before any handler
runs, so their body is only {"message":"Unauthorized"} or {"message":"Forbidden"},
not the error envelope the rest of this page shows.
1. Get an API key
Sign in to the dashboard at https://app.gitloom.cloud and create a key. The key is
shown once, at creation; only its hash is stored, so it cannot be recovered or
re-displayed. If you lose it, revoke it and mint another.
Keys look like this:
gl_live_3f9a2b7c1d4e6f80_kQ8vN2xR7mZpLd3Wc0Ty5Ub9Aj1Hs4Ge
└┬─┘└─┬┘ └──────┬───────┘ └──────────────┬──────────────┘
│ │ │ └─ secret, never stored
│ │ └─ key id: appears in logs, used to revoke
│ └─ environment: live | test
└─ prefix, so secret scanners can match it
Export it:
export GITLOOM_API_KEY="gl_live_3f9a2b7c1d4e6f80_kQ8vN2xR7mZpLd3Wc0Ty5Ub9Aj1Hs4Ge"
Confirm the key resolves to your account:
curl -s "$GITLOOM_API/v1/whoami" \
-H "Authorization: Bearer $GITLOOM_API_KEY"
{"account":"aa828f01c848e3b","auth":"api_key","chat_url":"https://hbo7a6docziydxcw6rnpav5hfi0jdgjv.lambda-url.ap-south-1.on.aws/","env":"live","plan":"free"}
## 2. Create a namespaceA namespace is one memory — its own git repository, its own index, isolated from every other namespace in your account. The usual pattern is one namespace per end user.
Namespaces are created explicitly. Writing to one that does not exist is a 404, not
an implicit create — a typo in a user id must not quietly mint an empty memory and
report success.
curl -s -X POST "$GITLOOM_API/v1/namespaces" \
-H "Authorization: Bearer $GITLOOM_API_KEY" \
-H "content-type: application/json" \
-d '{"namespace":"user-8213"}'
{"created":true,"namespace":"user-8213"}
Calling it again is success, not a conflict:
{"created":false,"namespace":"user-8213"}
201 means it was created, 200 means it already existed. Because it is idempotent
you can call it on every application startup without branching.
A namespace name may contain lowercase letters, digits and -, up to 64 characters.
Anything else is 400 invalid_namespace. Omitting namespace gives you default,
which still has to be created before it can be used.
List them:
curl -s "$GITLOOM_API/v1/namespaces" \
-H "Authorization: Bearer $GITLOOM_API_KEY"
{"namespaces":["default","user-8213"]}
3. Write a memory
The usual write is a conversation: you send it, and GitLoom extracts the memories from it.
curl -s -X POST "$GITLOOM_API/v1/memories" \
-H "Authorization: Bearer $GITLOOM_API_KEY" \
-H "content-type: application/json" \
-d '{
"namespace": "user-8213",
"session_id": "chat-2026-07-31-a",
"occurred_at": "2026-07-31",
"messages": [
{"role": "user", "content": "I finally bought the Sony A7III today — 142k at Fotocentre in Bengaluru. Went with it over the A7IV purely on price."},
{"role": "assistant", "content": "Nice pick. With the 28-70 kit lens you already have, that pairs well for travel."},
{"role": "user", "content": "Yeah, I want it ready before the Japan trip in October."}
]
}'
{"id":"4231126e-d46e-481e-928c-f31f3a1f1731","namespace":"user-8213","status":"accepted"}
202, not 200. The memory does not exist yet. Extraction is several model
calls, so the write is queued and a worker performs it. See
Limits and behaviour for what that means in practice — in short, expect
seconds, not milliseconds, and there is no status endpoint to poll. The id is the
queue message id, useful in a support conversation and nothing else.
Fields:
| field | required | notes |
|---|---|---|
messages |
yes | at least one turn; role is user or assistant |
namespace |
no | defaults to default, which must still exist |
session_id |
no | your own id for the conversation; carried into ingestion |
occurred_at |
no | When the conversation happened, not now — a backfill of last year's transcripts must not claim everything happened today. Epoch seconds, RFC 3339, or a date or datetime without an offset, read in timezone. Omitted means now. date is its older name. |
timezone |
no | IANA zone, such as Asia/Kolkata, for an occurred_at without an offset. Default UTC. |
tags |
no | your tags, on every memory drawn from the conversation; lowercased, up to 32 of 64 characters |
A tag, time or zone the API cannot read is a 400 (invalid_tag,
invalid_date or invalid_timezone) naming the field, and nothing is queued.
Memories you have already formed go in memories instead of messages and are
stored as given; the API reference
has that shape.
The 404 you will hit first:
{"error":{"code":"namespace_not_found","message":"create namespace \"user-8213\" before writing to it"}}
That check runs at the front door, while you are still listening, rather than in the
worker — by the time the worker sees a job you already have a 202 in hand.
4. Retrieve
Give it the question, verbatim.
curl -s -G "$GITLOOM_API/v1/retrieve" \
-H "Authorization: Bearer $GITLOOM_API_KEY" \
--data-urlencode "q=what camera did I buy" \
--data-urlencode "namespace=user-8213"
{
"tenant": "aa828f01c848e3b",
"namespace": "user-8213",
"query": "what camera did I buy",
"mode": "raw",
"memories": [
{
"path": "facts/camera-gear/sony-a7iii-purchase-42065a87.md",
"tier": "facts",
"topic": "facts/camera-gear",
"title": "User bought Sony A7III on 2026-07-31 for 142,000 (currency unspecified) at Fotocentre in Bengaluru. Chose it over Sony A7IV primarily due to price.",
"content": "User bought Sony A7III on 2026-07-31 for 142,000 (currency unspecified) at Fotocentre in Bengaluru. Chose it over Sony A7IV primarily due to price.",
"snippet": "User bought Sony A7III on 2026-07-31 for 142,000 (currency unspecified) at Fotocentre in Bengaluru. Chose it over Sony A7IV primarily due to price.",
"score": 0.5770170341391747,
"matched": [
"lexical",
"cue",
"body"
],
"tags": [
"2026",
"camera",
"purchase"
],
"created": "2026-07-31T00:00:00Z",
"updated": "2026-09-27T17:13:43Z",
"confidence": 0.95,
"scores": {
"bm25": 0.9676988712202653,
"cue": 0.2584483027458191,
"body": 0.201714888215065,
"coverage": 1
},
"provenance": {
"commit": "105ca74b0fd732a3bba3e497752b3d5f42bccfe7",
"author": "gitloom",
"when": "2026-09-27T17:13:43Z",
"message": "remember(2026-07-31): 3 memories from session chat-2026-07-31-a",
"revisions": 1
}
}
],
"candidates": 3,
"filtered_out": 2,
"millis": 82,
"timings": {
"lexical_ms": 4,
"vector_ms": 81,
"graph_ms": 0
},
"cold_start": false
}
Parameters:
| param | default | notes |
|---|---|---|
q |
— | the question; leave it out and give a filter to list what the filters match |
namespace |
default |
must exist, or 404 |
mode |
raw |
summary adds a model-written answer; agentic lets a model search with tools |
limit |
24 |
memories returned, max 200 |
tiers |
all | facts,incidents,rules,skills — comma-separated |
paths |
all | directories to confine the search to: facts/events,incidents |
tags / tags_all |
— | any of / all of these tags |
since / until |
— | bound time_field: epoch seconds, RFC 3339, or a date or datetime read in tz; a date alone in until includes that whole day |
time_field |
updated |
occurred (when it happened), created or updated |
tz |
UTC | IANA zone, such as Asia/Kolkata |
time_format |
epoch |
iso returns each memory's *_at times as RFC 3339 in tz instead of unix seconds |
min_score |
0 |
drop memories below this relevance |
context |
1 |
0 drops graph neighbours |
detail |
compact |
full adds history, the last diff, relation snippets and cues |
rank |
— | fused or jev: search on the lane path, which also reaches conversation turns and dates, and order the results by lane score or with TypeSafe's Jev ranking model (jev, outside AWS) |
max_chars |
0 |
cap the characters of memory content returned; memories that do not fit come back excerpted |
model |
the mode's | haiku or sonnet: the model that reads the memories in summary or agentic mode |
Notes on the response:
- Each entry is one whole memory.
contentis the full body, so for most questions the response is the answer material and nothing else needs fetching. A memory whose sections matched separately is still one entry, withsectionsnaming which. scoreis a calibrated relevance from 0 to 1, comparable across queries.matchedsays which arms produced it —lexical,cue,body,graph. A memory matched only bygraphis context that rode in beside a real match (vianames it), not evidence.- Filters apply to every arm.
paths=facts/commitmentsconfines lexical, semantic and graph search alike, so nothing outside that directory can appear. filtered_outwith an emptymemoriesmeans the question was asked of a memory that holds nothing relevant — retrieval abstained rather than returning the least-bad memories.- Each memory also carries its times in unix seconds.
occurred_atis when its subject happened;created_atandupdated_atare when the memory was written and last changed. Thecreatedandupdatedstrings shown above are deprecated. - The
timingstell you where time went.vector_msdominating is normal: it is the query-embedding round trip to Bedrock, not search.
Want one text answer instead of memories? Add mode=summary and read answer; the
memories still come back beneath it. mode=agentic runs a stronger model that
searches the memory itself and returns its trace. Both are metered as chats, not
reads, because they run a model.
5. The same thing in TypeScript
Node 20+, no dependencies — the shape the TypeScript SDK wraps. Reach for the SDK when you want the conversation managed for you; reach for this when you want to see exactly what goes over the wire, or you are writing the client for a language that has no SDK yet.
const API = process.env.GITLOOM_API!;
const KEY = process.env.GITLOOM_API_KEY!;
async function gl<T>(
path: string,
init: RequestInit & { query?: Record<string, string> } = {},
): Promise<T> {
const url = new URL(path, API);
for (const [k, v] of Object.entries(init.query ?? {})) url.searchParams.set(k, v);
const res = await fetch(url, {
...init,
headers: {
authorization: `Bearer ${KEY}`,
...(init.body ? { "content-type": "application/json" } : {}),
...init.headers,
},
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
// Handlers answer {error: {code, message}}. The gateway answers a missing (401)
// or refused (403) credential with only {message}; call that unauthorized, as the SDKs do.
const refused = res.status === 401 || res.status === 403;
const e = body.error ?? { code: refused ? "unauthorized" : undefined, message: body.message };
throw new Error(`gitloom ${res.status} ${e.code ?? "error"}: ${e.message ?? ""}`);
}
return body as T;
}
// 2. create the namespace — idempotent, safe on every startup
await gl<{ namespace: string; created: boolean }>("/v1/namespaces", {
method: "POST",
body: JSON.stringify({ namespace: "user-8213" }),
});
// 3. hand over a conversation; returns 202, the memory does not exist yet
const accepted = await gl<{ id: string; namespace: string; status: string }>(
"/v1/memories",
{
method: "POST",
body: JSON.stringify({
namespace: "user-8213",
session_id: "chat-2026-07-31-a",
occurred_at: "2026-07-31",
messages: [
{
role: "user",
content:
"I finally bought the Sony A7III today — 142k at Fotocentre in Bengaluru.",
},
{ role: "assistant", content: "Nice pick, that pairs well with your 28-70." },
],
}),
},
);
console.log(accepted.status); // "accepted"
// 4. retrieve — after ingestion has run; see Limits and behaviour
type Retrieved = {
namespace: string;
mode: "raw" | "summary" | "agentic";
memories: Array<{ path: string; content: string; score: number; matched: string[] }>;
answer?: string;
millis: number;
};
const r = await gl<Retrieved>("/v1/retrieve", {
query: { q: "what camera did I buy", namespace: "user-8213", limit: "8", tiers: "facts" },
});
for (const m of r.memories) {
console.log(m.score.toFixed(2), m.matched.join("+"), m.path);
console.log(" ", m.content);
}
Where this goes next
The retrieved memories are context, not an answer. The intended shape is: retrieve
(milliseconds, no model call), then hand the memories to whatever model is already
answering your user. When you would rather GitLoom answered, mode=summary makes one
fast model call over the same retrieval and mode=agentic lets a stronger model search
for itself — both cost a chat rather than a read, which is why they are opt-in.
- Core concepts — accounts, namespaces, memories, cues, and why git
- Limits and behaviour — asynchrony, latency numbers, and what is not enforced yet
- API reference — every route, and the full OpenAPI description
- Plans and pricing — what Free includes, and what happens past it