GitLoom

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 namespace

A 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:

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.