kagura-memory
v0.14.1
Published
TypeScript SDK for Kagura Memory Cloud — memory management, graph exploration, and R2 file storage for AI agents
Maintainers
Readme
What is this?
This SDK connects your TypeScript/JavaScript code to Kagura Memory Cloud, giving AI agents the ability to remember, search, and learn from past interactions. It is a TypeScript port of the Python SDK core:
| Client | Protocol | Use Case |
|--------|----------|----------|
| KaguraClient | MCP (JSON-RPC) | Direct memory ops — remember, recall, explore, reference, forget |
| ResourceClient | REST API | External data ingestion — push data from Slack, CI/CD, CRM into Kagura |
| FilesClient | REST + presigned PUT | File uploads with sha256 integrity binding (R2) |
| WorkspaceClient | REST API | Workspace member, invitation, and API key management |
| SecretClient | REST API | Zero-knowledge secrets — age-encrypted on your machine, opaque to the server |
| AgentsClient | REST API | Agent bootstrap for API-key-only callers (no MCP session) |
| MemoryClient | REST API | Tool guardrails for API-key-only callers — the AGENTS.md export digest and the guardrail set |
A kagura-memory command-line tool ships alongside it, mirroring the Python
CLI's kagura command — see Command line.
For Claude Desktop, the MCPB extension provides browser sign-in
and a refresh-aware connection without Python or a global CLI install. The npm
package also includes kagura-memory-mcp, a transparent stdio/HTTP proxy.
Installation
npm install kagura-memoryRequires Node.js >= 18 (native fetch). Zero runtime dependencies —
the one optional peer dependency, for
zero-knowledge secrets, is never installed
unless you ask for it.
Targets memory-cloud v0.75.0 (MIN_SERVER_VERSION) and is checked
against memory-cloud up to v0.77.0, as the Python SDK 0.42.0 is (whose
own MIN_SERVER_VERSION stays at an advisory 0.17.1).
checkServerVersion() warns, and never throws, on an older server, which
still answers: it ignores options it predates, leaves out fields it
predates, and reports a tool it predates as not found. A pre-release of
the minimum (0.75.0-rc1) counts as older, and a version it cannot read
(0.75, main-abc123) gets no warning, as in the Python SDK. The method
notes below say which server version a feature needs.
Quick Start
import { KaguraClient } from "kagura-memory";
// Credentials resolve automatically: explicit apiKey > KAGURA_API_KEY env
// > OAuth profile (~/.kagura/credentials.json, shared with the Python CLI)
// > .kagura.json
const client = new KaguraClient();
// Store a memory
const stored = await client.remember({
contextId: "your-context-id",
summary: "User prefers TypeScript strict mode",
content: "Enabled strict + noUncheckedIndexedAccess in all new projects.",
type: "note",
tags: ["typescript", "preferences"],
});
// Search memories (hybrid semantic + keyword)
const hits = await client.recall({
contextId: "your-context-id",
query: "typescript preferences",
k: 5,
});
for (const memory of hits.results as Array<Record<string, unknown>>) {
console.log(memory.summary);
}
await client.close();Authentication
Four ways to authenticate, in resolution order:
- Explicit key —
new KaguraClient({ apiKey: "kagura_..." }) - Environment —
KAGURA_API_KEY(+ optionalKAGURA_MCP_URL) - OAuth profile —
~/.kagura/credentials.json, written bylogin()(below) or the Python CLI'skagura auth login. Profiles (KAGURA_PROFILEenv or{ profile: "name" }) and auto-refresh work exactly like the Python SDK; the credentials file is shared between both SDKs. - Config file —
.kagura.jsonin the working directory or home:
{
"api_key": "kagura_your_api_key",
"mcp_url": "https://memory.kagura-ai.com/mcp"
}Credentials travel over HTTPS only. Every client, login(), auth login
--server and the URL every setup subcommand puts in a harness entry
(setup claude, setup codex, setup hermes and setup openclaw)
refuse a plain-HTTP URL unless its host is localhost, 127.0.0.1 or
[::1]; doctor warns about a resolved MCP URL like that and skips the
server check, as the Python CLI does, and fails a configured mcp_url
like that which the credential does not use (see doctor). The URL is
read as fetch reads it, so the scheme in any case (HTTP://), without
its slashes (http:host), or with whitespace or control characters
around it or a tab or newline inside it is refused all the same, as in
the Python SDK.
Command line
kagura-memory mirrors the Python CLI's kagura command: its subcommands
and flags take the Python CLI's names, and it prints the same JSON on
stdout and exits with the same codes (2 for a usage error, 1 for a runtime
failure). An option a command, a group or the root does not take is
refused in the words of click 8.3, the version the Python CLI's lockfile
pins: Error: No such option: --x, naming the option without any value
given in it, and for a long option close to one the command takes,
click's suggestion after it (Did you mean --json?, or (Possible
options: --end, --period) for more than one); click 8.4 and later write
No such option '--x'.. An option given no value is Error: Option '-w'
requires an argument.. Either error wins over --help, as click reads
the whole command line first, and only the first one is reported,
followed by the command's help (where click prints its usage line and a
Try '… --help' hint first). An unknown option does not win over
--help where the Python CLI passes such options on
(ignore_unknown_options: secret exec, and measure record for a
negative VALUE), so secret exec --help --bogus prints the help; without
--help, secret exec still refuses an unknown option before the child
command rather than running it as that command. The one command not
ported, the few options only this bin has, and the deliberate
differences are listed below. The parity target is the Python CLI
0.42.0.
Values are read as click reads them. A choice matches exactly, as click's
Choice does, except where the Python CLI declares one case-insensitive
(--source-type, --progress), casefolded as click folds it; a number
is read as Python's int() and float() read it, 1_000, any decimal
digit (١٢, 12) and the whitespace they skip included (a BOM is no
whitespace to them); an id argument (TOKEN_ID, INVITATION_ID,
KEY_ID) is sent exactly, an integer however large, or a string id
(rtok_…, winv_…, akey_…, memory-cloud v0.89.0+) as typed; and an
out-of-range value is echoed as click converted it (files upload
--importance 2 is refused as 2.0 is not in the range 0.0<=x<=1.0.).
A failure prints Error: <message> on stderr, and a quota or plan
refusal adds the lines the Python CLI adds: Resets at: <time>, in
Python's isoformat() form, and Required plan: <label> (<key>).
npx kagura-memory --help| Group | Commands |
|---|---|
| auth | login logout refresh status use list token create-key list-keys revoke-key |
| context | list create update delete search-config (plus the contexts alias) |
| memory | remember recall reference forget update-memory explore |
| edge | list create update delete |
| sleep | history report rollback |
| measure | record series |
| files | upload list delete download-url |
| guardrails | load digest |
| resource | tokens {list,create,update,revoke} list setup schema stats indexer-status events ingest ingest-batch import |
| secret | keygen list put get grant revoke rotate delete pubkeys approve audit-verify exec |
| setup | claude codex hermes openclaw |
| workspace | member {list,add,set-role,remove} invite {create,list,revoke} |
| other | config show doctor |
npx kagura-memory auth login --profile work --read-only
npx kagura-memory recall "OAuth setup" -c dev -k 10
npx kagura-memory recall "dependency injection" --no-rerank # skip reranking; no flag follows the context config (v0.69.0+)
npx kagura-memory remember -s "FastAPI DI" --content "Use Depends()" --tags "python,fastapi"
npx kagura-memory measure record <context-id> weight_kg 71.5 --unit kg
npx kagura-memory setup codex --dry-run # show the command and the entry; change nothing
npx kagura-memory doctorThe context id comes from -c/--context-id, or from context_id in
.kagura.json. Credentials live in ~/.kagura/credentials.json and are
shared with the Python CLI, so either tool can create a profile the other
then uses.
resource setup names the new context after the resource, or -n/--name
(lowercase letters, digits, - and _, at most 100), which a workspace
that already has a context of that name needs. It prints Python's six
fields, context_id, context_name, resource_id, token, token_id
and warning (null when the server sends none); the token is shown this
once. --summary is accepted and ignored, with a note on stderr, since
the server's setup_resource has no summary: the context's owner sets it
afterwards with kagura-memory context update <context_id> --summary ….
As in Python, resource tokens revoke prints Token revoked., and
resource schema prints No schema registered for this resource. when
there is none (the route's 404).
Records print as the Python CLI prints them. resource tokens
list|create|update, resource list|stats|indexer-status|events|schema,
resource ingest|ingest-batch|setup, files list and files upload
print the result as the Python CLI does, model_dump_json(indent=2) of
the Python SDK's model
(#66):
the model's keys in its order, its default for a key the server left out
(null, false, 0, [], {}, "success"), and no key it does not
have. Each value is what the model's type makes of it: "12" for an int
prints 12, a float prints as one (1.0, 1e-6), a resource event's id
past 2^53 prints exactly, and a timestamp prints in pydantic's form
(2026-06-01T09:00:00.5+00:00 as 2026-06-01T09:00:00.500000Z), which
is the form memory-cloud already sends. A record the model refuses (a
required field missing, a value of the wrong type) exits 1 with the
Python SDK's KaguraResponseError text, e.g. ResourceClient.list_tokens:
unexpected server response for PaginatedResourceTokensResponse
(tokens.0.created_at: Field required). …, and so does a body that is no
record at all (null). An untyped mapping nested past pydantic's limit
(256 containers) exits 1 with pydantic's Error serializing to JSON:
ValueError: Circular reference detected (depth exceeded). The body is
read as Python's json.loads reads it
(#69),
so an int field sent 9007199254740993 prints exactly, one sent 1e20
exits 1 as pydantic refuses it (Unable to parse input string as an
integer, exceeded maximum size), and a float field sent -0 prints
0.0. Inside an untyped mapping (an event's payload, a batch's
errors) the keys print in the server's order ({"b": 1, "2": 2} stays
so) and each number as Python read it (1.0, 1e+16, -0.0, an
integer past 2^53 exactly). NaN and Infinity are read, and print
null as pydantic writes them; an integer of more than 4,300 digits
makes the body non-JSON, as Python's int() limit does; and a body
nested deeper than 973 containers exits 1 with Python's maximum
recursion depth exceeded while decoding a JSON array from a unicode
string. A string value holding a lone surrogate ("\ud800") exits 1
with pydantic's Error serializing to JSON: UnicodeEncodeError: 'utf-8'
codec can't encode character '\ud800' in position 0: surrogates not
allowed; a key of the untyped mapping itself holding one prints as
three U+FFFD, as pydantic converts it, and a key of a mapping nested
inside it exits 1 like a value. Three differences remain. Python's depth
is its recursion limit less the stack in use: 973 is what these commands
reach, and doctor given such a /api/v1/system/info body reports a
failed server check where Python stops with a traceback. The body is
decoded as UTF-8, as fetch decodes every body: one that is not valid
UTF-8 is read with U+FFFD in place of the bad bytes, where Python calls
it non-JSON, or, for a lone surrogate sent as its three raw bytes
(ED A0 80), reads it and then refuses the dump; and a UTF-16 or UTF-32
body, with or without a BOM, which Python's json.loads detects and
reads, is non-JSON here (Server returned a non-JSON body (HTTP 200)
…). No memory-cloud server sends either. And only REST bodies are read
this way: the MCP commands (recall, remember, …) still print a whole
float as 1 and list an object's integer keys first.
Files and imports. The files commands take their workspace from the
credential's own source, as the Python CLI does (its #115): -c when
given, else the OAuth profile's workspace, else the context_id of the
.kagura.json that holds the key; a KAGURA_API_KEY key needs -c.
files delete prints Deleted <file_id> and files download-url the bare
URL. files upload --remember writes the memory the Python CLI writes
(source_uri the file's file:// URI, details the file's id, sha256,
size and type), and prints {"file": …, "memory": …}, the file as its
model reads it. resource import detects the format by the file's
extension, so stdin needs --format, and reads CSV and JSON the way
Python's csv and json modules read them, with their error messages
(exit 1). A row keeps its keys in the order read, and a doc_id taken from
a number is Python's str() of it as written (1234567890123456789,
10.0, 1e+20). A NaN or Infinity in the input fails the batch
holding it, as the Python CLI's request encoding does (Out of range float
values are not JSON compliant: nan), rather than being sent as null. A
doc_id outside 1-255 characters and a CSV row with more cells than the
header are refused before anything is sent, where the Python CLI stops
with a traceback. Two limits of JavaScript numbers remain: a number in the
payload is sent as JavaScript reads it, so an integer past 2^53 loses its
last digits and 10.0 is sent as 10, where Python sends them as
written; and -V above 2^53 is refused as too large (exit 2), where
Python sends it. The summary prints as Python's json.dumps prints it
(#69):
created and failed add up exactly past 2^53, and each error keeps
the server's key order and numbers (1.0, 1e+16, NaN); a lone
surrogate in an error's string or key prints as ? (one per code unit,
"😀???x"), as the Python CLI's UTF-8 stdout with errors="replace"
writes what json.dumps kept. The counts in
--progress json's events (detail.created_so_far, the success event's
detail.created) are still JavaScript numbers, so past 2^53 they are
rounded where Python's are exact; -v prints no counts.
Progress. files upload and resource import take -v/--verbose
(repeatable) and --progress rich|json|none. Progress goes to stderr, one
line per event, so stdout carries the same result either way.
--progress json writes the Python CLI's NDJSON,
{"v": 1, "ts": …, "stage": …, "kind": …, "msg": …, "detail": …}, whose
last event is the operation's single success or error; -v, or
--progress rich, writes the lines the Python CLI shows
(→ Reserving upload report.pdf (1234 bytes), ✓ Upload complete,
✗ Upload failed: …); --progress none is silent even with -v.
As in the Python CLI (0.41.1+), files upload --remember reports success
only once the linked memory is written (a failed write ends the stream
with that error), and resource import starts its stream only once its
client is built, so a credential that fails prints the error alone. A
stderr closed early (2>&1 | head -1) does not stop the upload or the
import. Ctrl-C ends the process without a final event (Node's default
SIGINT handling exits at once), where the Python CLI catches the
interrupt and ends the stream with error.
Connecting a harness. Each setup subcommand sets up an MCP entry
named kagura-memory: the URL plus a Bearer header, or, with
--url-form --oauth (below), the URL alone, which the harness signs in
to itself. The key is never printed, and no harness CLI gets it on its
command line.
setup claude also writes .kagura.json (0600, gitignored). Its key
comes from --api-key, else the api_key in the project's own
.kagura.json (the one in --project-dir), else KAGURA_API_KEY; the
URL likewise from --mcp-url, else that file's mcp_url, else
KAGURA_MCP_URL, an empty --mcp-url counting as none, as Python's or
reads it. It never takes them from ~/.kagura.json, nor, with
--project-dir, from the current directory's .kagura.json: those are
another project's credentials. .kagura.json gets the URL as given; the
parameters that --guardrails and --tool-profile set go on the entry's
URL only. A plain-HTTP URL to a host other than localhost is refused,
whichever of the three it came from, before anything is written or run
(exit 1, Connection failed: MCP URL must use HTTPS for security …, the
Python CLI's words, whose connection test refuses it).
Without --agents-md, setup codex, setup hermes and setup openclaw
never see, write, print or pass the key, as in the Python CLI. The entry
names the environment variable the harness reads the key from, and the
notes say where to put it; a missing key is no error. These three write
no config file themselves: no .kagura.json, no .gitignore line, no
harness .env. The harness's own CLI writes the entry, or you add the
printed block. The one file they may write is the AGENTS.md export
(--agents-md, below), which is fetched with the usual credential: with
it, a missing credential stops setup (exit 1).
| Subcommand | How the entry is applied | Where the key goes |
|---|---|---|
| setup claude | .mcp.json (--scope project, the default), or claude mcp add-json --scope user … | --scope project: in .mcp.json; --scope user: KAGURA_MCP_API_KEY, exported where Claude Code starts |
| setup codex | codex mcp add … --bearer-token-env-var KAGURA_API_KEY (with --oauth: codex mcp add NAME --url URL, attached, when there is a terminal and no -y) | export KAGURA_API_KEY=… in the shell profile that starts Codex |
| setup hermes | the config.yaml block is printed; hermes mcp add is interactive, and this port never prompts (with --oauth: hermes mcp add … --auth oauth, attached, when there is a terminal and no -y) | MCP_KAGURA_MEMORY_API_KEY=… (MCP_<NAME>_API_KEY with --name) in the .env beside config.yaml, added with an editor |
| setup openclaw | openclaw mcp add … --transport streamable-http --no-probe, or openclaw mcp set with --force (with --oauth: the same two commands with --auth oauth and no header) | KAGURA_API_KEY=… in $OPENCLAW_STATE_DIR/.env (default ~/.openclaw/.env), added with an editor |
--api-key-env VAR renames KAGURA_API_KEY for Codex and OpenClaw
(an upper-case letter or _, then upper-case letters, digits or _).
Hermes names its variable itself, so setup hermes refuses the option
(exit 2). The entry goes into Codex's $CODEX_HOME/config.toml (default
~/.codex/config.toml), into Hermes's config.yaml in $HERMES_HOME or
else the active Hermes profile's directory (~/.hermes/profiles/<name>
when ~/.hermes/active_profile names one, else ~/.hermes), or into
OpenClaw's $OPENCLAW_CONFIG_PATH (default openclaw.json in the state
directory). The closing notes name the command that checks it:
codex mcp get kagura-memory, hermes mcp test kagura-memory or
openclaw mcp doctor kagura-memory --probe. --mcp-url defaults to the
configured mcp_url, then https://memory.kagura-ai.com/mcp. It is read
as a URL parser reads it, as in Python (python-sdk#279): whitespace and
control characters around it, and any tab or newline inside it, are
dropped before the checks, and the entry gets that URL. A plain http://
--mcp-url other than localhost, in any spelling, is a usage error
(exit 2), since the entry sends the key there with every request; so is
anything but an http(s) URL with a host (--mcp-url=--help,
memory.kagura-ai.com/mcp, https://[::1/mcp), which would go on the
harness's command line after --url. A configured mcp_url gets the same
checks, and exits 1 saying to pass --mcp-url. $OPENCLAW_STATE_DIR,
$OPENCLAW_CONFIG_PATH and $OPENCLAW_WORKSPACE_DIR are read as OpenClaw
reads them: stripped, with a leading ~ expanded.
The OAuth URL form (memory-cloud 0.77.0+). From 0.77.0 memory-cloud's
dynamic client registration accepts the three harnesses' own OAuth clients
on a loopback redirect
(memory-cloud#1657),
so --url-form --oauth --mcp-url https://memory.kagura-ai.com/mcp/w/<workspace-id>
writes a URL entry with no key, no header and no key variable: the harness
registers its own client, signs in itself and keeps the token in its own
store. Setup never sees the token, never runs a harness login, and never
picks this form on its own. Before it detects, runs or writes anything,
a real run sends one unauthenticated GET /api/v1/system/info to the
--mcp-url server and stops (exit 1) unless it reports 0.77.0 or later; a
version setup cannot read (no answer, not a 200, unparseable) stops it
too. --dry-run sends no request. --oauth needs --url-form and
--mcp-url and refuses --api-key-env (exit 2).
npx kagura-memory setup codex --url-form --oauth --mcp-url https://memory.kagura-ai.com/mcp/w/<workspace-id>| | The entry | Written with | Sign in | The harness keeps the token in |
|---|---|---|---|---|
| Codex | url only (Codex's auth defaults to OAuth) | codex mcp add <name> --url <url>, which saves the entry and then starts Codex's browser sign-in, so setup runs it attached to your terminal (its output on stderr), only with a terminal on stdin and without -y; otherwise it prints the table and edits nothing (--force: the same add, which signs in again) | codex mcp login <name> (--no-browser when the browser cannot reach the callback) | the OS keyring (Codex MCP Credentials; on Windows, Codex's encrypted secrets store in ~/.codex), else ~/.codex/.credentials.json ($CODEX_HOME), keyed on the entry's URL |
| Hermes Agent | url + auth: oauth | hermes mcp add <name> --url <url> --auth oauth --connect-timeout 315, attached, with a terminal and without -y: its probe runs the browser sign-in, which its default 30 s bound would cut short. Setup then reads the entry back with hermes config get mcp_servers.<name> --json: an entry Hermes kept, saved without auth: oauth, or saved disabled (enabled: false, after the sign-in did not finish) stops setup (exit 1) with the command that fixes it, and skips the AGENTS.md export | hermes mcp login <name> (the browser flow), or on memory-cloud 0.78.0+ hermes mcp login <name> --flow device (a code entered at the server's /device page; no loopback callback, memory-cloud#1671) | ~/.hermes/mcp-tokens/<name>.json ($HERMES_HOME, or the active Hermes profile's) |
| OpenClaw | url, transport: "streamable-http", auth: "oauth" | openclaw mcp add <name> --url <url> --transport streamable-http --auth oauth, which saves an OAuth entry without probing (--force: openclaw mcp set, even when no entry exists, where Python then uses mcp add) | openclaw mcp login <name> (--code <code> when the browser cannot reach the callback), then openclaw mcp doctor <name> --probe | its state database, ~/.openclaw/state/openclaw.sqlite ($OPENCLAW_STATE_DIR/state/) |
memory-cloud's consent screen shows the client name the harness sends,
which nothing verifies: approve only a sign-in you started. For Codex,
-c or --guardrails goes on the URL as ?guardrails=; Codex keys its
token on the URL, so changing it later means signing in again. With the
plugin's hooks on, an --oauth entry does not get ?guardrails=off (the
hooks cannot read it), and setup says so. The --guardrails preview runs
on this CLI's own credential when that is on the --mcp-url server;
otherwise it names the stored profiles there and runs on the first, or,
when there is none, names the kagura-memory auth login --server to run
first. What Codex receives depends on the account it signed in with.
What has been checked (python-sdk#284): on 2026-09-25 each harness signed
in end to end on a /mcp/w/<workspace-id> URL against memory-cloud
0.78.0, through entries the Python CLI's setup … --url-form --oauth
wrote, and reached tools/list: Codex 0.157.0, Hermes Agent v2026.9.24
(the browser sign-in and hermes mcp login --flow device) and OpenClaw
2026.9.6. This bin writes the same entries with the same harness commands.
This package has no TOML, YAML or JSON5 parser, so it never rewrites those
files. When the harness's CLI is not on PATH, the block is printed on
stderr with the file it belongs in, and stdout stays one JSON document.
When Hermes's config.yaml already has an mcp_servers: key, only the
kagura-memory entry is printed, to go under it: a second top-level
mcp_servers: would replace the first, and every server in it. An entry
of the same name, which setup finds by scanning the file, stops the run
(exit 1, nothing changed) unless you pass --force. For Codex that holds
always; for Hermes and OpenClaw only with their CLI on PATH, since the
Python CLI finds their entries only through it. Without the CLI, setup
prints the block to go in place of the old entry and exits 0. A
config.toml that cannot be read stops setup codex (exit 1), as in
Python. A Hermes config.yaml that cannot be read, or is not UTF-8,
stops nothing: setup prints the whole block with Python's note to put
only the entry under an mcp_servers: key the file may have. An OpenClaw
file that cannot be read stops nothing either, since Python never reads
it, and a note says setup could not look there.
On Windows, a CLI installed only as an npm .cmd shim counts as not
found: Node runs one only through a shell, which would re-parse the
arguments.
With --scope user, the Claude Code entry does not hold the key: it
sends Authorization: Bearer ${KAGURA_MCP_API_KEY}, which Claude Code
fills in from its own environment each time it connects. The key is never
on the claude mcp add-json command line, where any local user could read
it in the process list, and never in ~/.claude.json. Set the variable in
the environment that starts Claude Code, e.g. export
KAGURA_MCP_API_KEY=kagura_xxx in your shell profile; setup says whether
the current shell has it, and doctor warns when it is unset. It is a
separate variable from KAGURA_API_KEY, which the SDK ranks above
.kagura.json and OAuth profiles for every command. The entry is the one
the Python CLI writes, so a run of either CLI finds the other's entry up
to date. --scope project still writes the key into .mcp.json, so keep
that file out of version control (setup adds it to .gitignore). A
user-scope entry this CLI wrote before 0.11.0 still holds the key in
~/.claude.json: re-run setup claude --scope user with
KAGURA_MCP_API_KEY exported to replace it (doctor warns about one).
--guardrails <context-id|off> (memory-cloud v0.74.0+) sets the URL's
guardrails parameter on setup claude and setup codex. On setup
codex, when neither the flag nor the MCP URL (--mcp-url or the
configured mcp_url) sets one, it defaults to off while the Kagura
plugin's Codex hooks are on for the entry (their config.json names its
table in mcp_server, kagura-memory by default), and otherwise to
-c. With a context there, the notes give the kagura-memory guardrails
digest … --target instructions command, which previews what Codex
receives. Hermes and OpenClaw do not read the server's instructions.
There --guardrails off is refused (exit 2), and a context id is not
written, whether it comes from the flag or from the URL: one warning
names what was dropped. A first ?guardrails=off in the URL, the value
the server reads, is kept, alone and with a warning of its own. Names
compare decoded, as the server reads them (guard%72ails is
guardrails). All of this is as in Python. --tool-profile (claude,
codex; memory-cloud v0.73.0+) sets profile and refuses an empty name.
The server knows
full and core (case-sensitive) and fails tools/list for any other
name, which leaves the harness with no Kagura tools. It applies a
?tools= allowlist already on the URL instead, so that case gets a
warning. Both go at the end of the query, guardrails
first, replacing any value already there, as the Python CLI writes them.
A setup claude run that leaves out a guardrails or profile value
the entry it replaces had says so in a note. --name, --force and
--dry-run (codex, hermes, openclaw) name the entry, replace an existing
one, and show the command or block without changing anything. A name is
1-64 letters, digits, - or _, starting with a letter or digit, as in
Python: it is a bare argument to codex and openclaw, which would read
--help as an option. Their --context-id must be a context UUID
(exit 2 otherwise, a padded or empty one included), as in Python without
--profile: this port lists no contexts.
The AGENTS.md export. --agents-md [PATH] on setup codex, setup
hermes and setup openclaw (memory-cloud v0.74.0+) fetches the
context's tool guardrail export block (MemoryClient.getGuardrailDigest)
and splices it into a file the harness loads every session, between its
kagura-memory:guardrails marker lines: an earlier block is replaced in
place, an unchanged set rewrites nothing, and the rest of the file is
kept, line endings and a symlink included. Without PATH the file is the
harness's own: $CODEX_HOME/AGENTS.md (AGENTS.override.md when that
exists); for Hermes the file Hermes loads from the current directory, found as
Hermes finds it: the nearest .hermes.md or HERMES.md up to the git
root (an empty one ends that search), else this directory's
AGENTS.override.md, AGENTS.md or agents.md (a new AGENTS.md when
only a parent directory's loads), else its CLAUDE.md or claude.md,
else a new AGENTS.md, so your own file keeps loading. With only Cursor
rules (.cursorrules, .cursor/rules/*.mdc) there is no default, since a
new AGENTS.md would stop Hermes loading them: --agents-md then needs a
PATH (exit 1, nothing run), and a dry run says the export is not offered;
for OpenClaw AGENTS.md in its workspace,
$OPENCLAW_WORKSPACE_DIR, else workspace/ in the state directory. A
leading ~ or ~/ in PATH is expanded to your home directory (a
~user is left as written, as in the Python CLI), and missing
directories are created. The context is --context-id, else a
--guardrails context UUID, never the
URL's ?guardrails= or .kagura.json; without one, --agents-md is a
usage error (exit 2). The block is fetched on the usual credential chain
(KAGURA_API_KEY, the OAuth profile, .kagura.json), which setup
settles before it runs anything, a dry run included: no credential, or
one for another server than the entry's, stops setup (exit 1) with
nothing run. The export comes after the entry is applied or printed.
wrote lists the file, and the notes carry Python's lines: the refresh
command (kagura-memory guardrails digest <ctx> --out <file>), a warning
past the 32 KiB Codex reads or the 20,000 characters OpenClaw reads, and
Hermes's prompt-injection note. An empty digest removes an earlier block,
as guardrails digest --out does, and otherwise writes nothing, creating
no file or directory. A failed export prints the report, then the error
(exit 1). --dry-run
names what the export would do to the file (create it, append the block,
replace the block, or update a file it cannot read). On Hermes and
OpenClaw a run without the export ends with Python's "Re-run with
--agents-md --context-id …" hint, except where Hermes has no default
file.
Claude Code uses the kagura-memory entry from the strongest scope
(local > project > user). It keys local scope by the git repository root
(a linked worktree's main working tree), and takes project scope from the
closest .mcp.json that defines the entry, in the directory it runs in or
any parent; setup claude and doctor read them the same way.
setup claude writes nothing when a stronger scope already defines one,
and prints the claude mcp remove --scope … command for it, after a cd
into the directory that command must run in when that is not the current
one. An entry the new one hides, in a weaker scope or in a parent's
.mcp.json, is noted. Messages name ~/.claude.json by the file actually
read: $CLAUDE_CONFIG_DIR/.claude.json when that is set.
With --scope user, an identical user-scope entry is left as it is and a
different one is replaced: claude mcp remove runs before claude mcp
add-json, and if the add then fails, the old entry is put back. An old
entry that holds a key (as this CLI wrote before 0.11.0) is never put
back, since that would pass the key on a command line; when it is not put
back, or putting it back fails, setup prints the command that re-adds it,
the key masked as <your-api-key>.
Without claude on PATH, --scope user prints the commands to run, and
where to set KAGURA_MCP_API_KEY, and writes nothing, unless the
user-scope entry is already identical. When claude plugin list --json
shows the Kagura Memory plugin enabled, the notes list the plugin settings
to enter, and say that the plugin has one guardrail context for every
project and authenticates only with a user API key. setup claude writes
.kagura.json and the MCP entry only; it installs none of the Python
CLI's hooks or /kagura-recall and /kagura-remember commands. Their
flags (--[no-]session-hook, --[no-]sync-hook, --[no-]commands) and
--no-auto-context are accepted, so a script written for the Python CLI
still runs, and change nothing. doctor reports the entry Claude Code
uses in the current directory, with its scope and file, and warns about
each entry that one hides. It also warns when a header of that entry
sends a ${VAR} that is unset in the current environment, or when that
entry is in user or local scope and holds the key itself, in
~/.claude.json, saying how to replace it; and for a
kagura-mcp stdio entry (the Python CLI's --profile form) checks that
kagura-mcp is on PATH. For a type: "url" entry, which earlier
releases wrote and Claude Code skips, it names the fix for the entry's
scope.
Not ported. kagura ingest needs the text-extraction pipeline (PDF,
Office, EPUB, audio), which does not exist in this package and would cost
the zero-dependency promise; use the Python CLI for it. The Claude Code
extras of kagura setup claude are not ported either: its SessionStart
and PostToolUse hooks and its /kagura-recall and /kagura-remember
commands. setup claude here writes .kagura.json and the MCP entry
only, and takes their flags as inert (see above).
Nor are the skills of the Python CLI's Claude Code plugin, such as the
memory skill of 0.42.0 (python-sdk #248), which walks an agent through
remember, recall, reference, update-memory and forget and their
--details/--location rules: this package ships no Claude Code plugin.
Every other command of the Python CLI is here as of 0.12.0 (#57), with the differences in options and output this section lists.
Only in this bin. secret keygen --reveal: keygen prints the private
key (see key custody below) and refuses to print it to a terminal without
--reveal. -c is short for --context-id on every setup subcommand.
setup codex also takes --tool-profile, which the Python CLI has only on
setup claude. auth refresh --no-browser: a refresh that widens
--scope re-runs the device flow here too, and this skips opening the
browser, as on auth login; Python's auth refresh takes only
--profile and --scope. -h is short for --help everywhere, where
the Python CLI takes only --help. setup codex, setup hermes and
setup openclaw still
accept --api-key and --project-dir, which they took before 0.11.0, so
older scripts still run. Neither does anything now, and a note says so.
--api-key beside --profile is still the usage error (exit 2) it was,
with --url-form too.
Five deliberate divergences.
secretneeds the optionalage-encryptionpeer (npm install age-encryption), exactly as Python's[secret]extra works. Key custody differs: Python uses the OS keychain viakeyring, which has no zero-dependency equivalent in Node, so this CLI reads the age identity fromKAGURA_AGE_IDENTITYorKAGURA_AGE_IDENTITY_FILEand fails closed when neither is set. A key custodied by the Python CLI is not readable here, and vice versa.setup … --profile(the OAuth path) writes an entry that launches Python'skagura-mcpstdio proxy, which this package does not install. Everysetupsubcommand reports the fact, and names the Python CLI'skagura setup <harness> --profilecommand, instead of writing a config that would fail at launch. The--api-keypath ofsetup claudeworks here, and so does the URL form of the other three. With--url-form, those three ignore--profile, with a note: the Python CLI uses it there to check the login, list contexts and fetch theAGENTS.mdexport with that profile alone. This port lists nothing and checks no login, and fetches the export on the usual credential chain, whereKAGURA_PROFILEpicks a profile.config showdoes not reproduce Python's key mask (key[:8] + "..." + key[-4:]), whose halves overlap below 12 characters and print the whole secret twice.--progress rich, and-vwithout--progress, print the Python CLI's→/✓/✗lines as plain text, the text Rich writes when stderr is not a terminal: no colours even on a terminal, no wrapping at its width, and no Rich markup, soreport[bold].pdfprints as written (Rich drops the[bold], and a[/…]in an error message makes it fail).-vvand-vvvshow one line per detail or debug event, where Rich draws a panel; neither command emits one.--progress jsonis the same NDJSON.- With no
.kagura.json, the MCP-backed commands (recall,guardrails load,measure, …) anddoctorreach an OAuth profile's own server. The Python CLI takes the default URL its config loader fills in for the server, and sends the profile's token there. AKAGURA_API_KEYgoes toKAGURA_MCP_URL, else the default, in both.
setup codex, setup hermes and setup openclaw also differ from
the Python CLI in these ways, each on purpose:
- Every entry here is the URL form, so
--url-formis accepted and changes nothing by itself (--oauthneeds it, as in Python), and--mcp-urlfalls back to the configured URL where Python requires it (not with--oauth). Only that fallback needs the configuration: without--mcp-url, a.kagura.jsonthat cannot be loaded stops setup (exit 1); with it, setup goes on with a note, as Python never reads that file. -cmust be a context UUID with--url-form --profiletoo (exit 2), where Python looks a name up through the profile.setup hermesnever runshermes mcp addfor the API-key form, which prompts for the key; it prints the block, as Python does under-y. With--oauthit runs it attached, as Python does.- So nothing is read back with
hermes config getfor that form either: the Python CLI 0.41.1's stops after the add (an entry Hermes kept in place of the one asked for, an entry saved disabled, which points tohermes mcp test) and its warning for a URL entry saved without anAuthorizationheader cannot arise for the printed block. - An existing entry is found by scanning the file, and not described by
kind. A readable but malformed
config.tomldoes not stopsetup codex. - On Codex, a
?guardrails=already in the MCP URL (--mcp-urlor the configuredmcp_url) beats the hooks and-cdefaults. - Nothing prompts here (setup only hands the terminal to an
--oauthcodex mcp addorhermes mcp add), so--agents-mdalways needs a context, and the export is never offered: a dry run of Hermes or OpenClaw says "not offered: this port never prompts" (or "not offered with -y"). - When the export credential is for another server, the message
says what moves it here, where Python's points at
--profile: forKAGURA_API_KEY, which outranks every profile,KAGURA_MCP_URLor unsetting the key; otherwiseKAGURA_PROFILE. - File errors of the export are Node's words, not Python's
[Errno …]. - Output is one JSON document on stdout, with Python's sentences in
notesand the block on stderr. A non-ASCII URL is written as UTF-8 where Python writes\uXXXXescapes; both are valid. --oauth: a harness CLI run attached writes its output to stderr, so stdout stays one JSON document. The server-check refusal ends "Use --url-form with an API key instead (no --oauth).", where Python also offers its stdio entry, which this bin cannot write. The Codex hooks warning appears only when the plugin's hooks are on for the entry (this bin does not read an existing entry's bearer), and setup does not ask "Write the --oauth entry anyway?": it warns and goes on, as Python does under-y. The--guardrailspreview never names--profile, which is inert here, and also names a.kagura.jsonthat is JSON but not an object, which every command of this bin refuses (Python's loader takes one). Sentences Python wraps at 78 columns are one line here.
Some small divergences run the other way: this CLI refuses what click would accept.
--profile=and--scope=reject an explicitly empty value. An empty profile name would create a nameless profile and an empty scope would go to the server verbatim. Other options take--flag=as Python does, apart from--external-id=in theupdate-memorycase below.context update --lock --unlockis a usage error (exit 2, "mutually exclusive; pick one"), where click takes whichever flag comes last.--rerank --no-rerankis the same usage error, onrecalland oncontext search-config.auth logout --all --profile NAMEis the same usage error, where click ignores--profileand logs out every profile.update-memory --dismiss-supersede-candidateexits 1 beside any--external-id, before anything is sent. The Python CLI refuses only a non-empty one, and sends an empty--external-id=to the server; this CLI refuses that too, asupdateMemorydoes.-k=5(a short option,=, a value) reads5, where click reads=5as the value and then refuses it.- An option that takes a value does not take a following argument that
begins with a dash and is not a number (a lone
-is still a value):--name-contains -authis a missing value here (Error: Option '--name-contains' requires an argument.), where click takes-auth. Write--name-contains=-auth. A number is anythingfloat()reads, so-١is one.auth login --inviteis the exception, since an invite token may begin with a dash. - An id the command puts in a URL path,
FILE_ID(files delete,files download-url), the resource id (-r/--resource-idofresource stats,indexer-status,schema,ingest,ingest-batchandimport, andresource events RESOURCE_ID) andUSER_ID/--user, is refused when it is.,..or empty (exit 2,Invalid value for 'FILE_ID': '..' is not a valid file id.), before anything is read or sent. The Python CLI sends it, and URL resolution then drops or climbs the segment:files download-url ..asks for/api/v1/download-url. (From 0.41.1 it refuses aUSER_IDas this bin does.) Any other id is sent percent-encoded as one segment, so a/,?or#in it stays in the id rather than reaching another route or replacing the query (files delete 'x?workspace_id=…').
Differences from the Python CLI 0.42.0 in what it added, each on purpose or out of reach:
update-memory --detailsandremember --detailsrefuse text that is not JSON with JavaScript's parser message afterInvalid JSON for --details:, where Python prints itsjsonmodule's.update-memory --merge-detailsprints the size of a boundedreferencereply whendetails_total_charsis a whole number sent as a float (24000.0), which Python leaves out: JSON.parse cannot tell it from24000.getServerInfo,checkServerVersion,getEmbeddingStatus,getMemoryStats,findDuplicatesandlistEmbeddingModelscheck the body against the Python model and return it as the server sent it: keys the model does not have stay, and a lax value such as"3"for an int is not converted, where Python returns the model.getMemoryStatsaccepts rows withoutuse_count, which memory-cloud v0.34.0 and later never send. The Python SDK 0.42.0's model still requires it, so it refuses every non-empty page (memories.0.use_count: Field required).listMemoriesreturns its body unchecked. The Python SDK has read it throughMemoryListResponsesince 0.40.0.- The kept Hermes OAuth entry of 0.41.3 (python-sdk #287) cannot arise
here. In Python it arises when the API-key form runs
hermes mcp add, its overwrite prompt is declined, and anauth: oauthentry at the same URL stays in place. An--oauthrequest already matches that entry, so it never stops there. This port's API-key form never runshermes mcp add: it prints the entry to put in place of the existing one, as Python does under-y.
guardrails load and guardrails digest (memory-cloud v0.74.0+)
take the context as an optional argument, else context_id from
.kagura.json. load calls the MCP load_guardrails tool and prints the
set as the Python CLI prints it: the model's fifteen keys in its order,
without the context_display_name, context_is_private and
context_is_locked the tool adds, each item's eleven keys, and null for
a tool_trigger the Python SDK cannot read (match: null is filled in
when a trigger has none). A set missing a required field, a truncation
flag above all, is refused with the Python SDK's KaguraResponseError
text (exit 1) rather than printed as complete. --cap takes 1-1000.
digest reads the REST digest route with the credential chain (an API
key or an OAuth profile) and prints the text: the AGENTS.md export
block, or with --target instructions the MCP server instructions this
credential would receive, where --profile and --tools repeat an MCP
URL's ?profile= and ?tools=. A context with no tool guardrails prints
nothing, and says so on stderr. With --out FILE, it splices the block
into FILE between its marker lines: an earlier block is replaced in place,
an unchanged set rewrites nothing, and an empty set removes an earlier
block. It then prints one JSON line, {"path": …, "status":
"written"|"unchanged"|"removed", "tool_triggered_version": …}, with the
path as Python's pathlib writes it (./x/../X.md reads x/../X.md).
As in Python, --out does not expand ~, never creates a missing parent
directory, and keeps a symlink, the file's mode and its line endings.
Beside JSON's 1 for Python's 1.0 (an importance), two differences
remain: an item's created_at / updated_at is printed as
the server sent it, where Python reformats it and refuses one it cannot
parse; and a failed read or write of the --out file reads in Node's
words (ENOENT: no such file or directory, open '…') rather than
Python's ([Errno 2] No such file or directory: '…').
doctor checks the credentials, the kagura-memory entry Claude Code
uses, the optional age-encryption peer and the age identity, then the
server, whose checks are the Python CLI's: Server reachable, then
Version: <version> by the same comparison checkServerVersion makes
(#66).
The version passes at or above MIN_SERVER_VERSION (0.75.0), fails below
it (Version: 0.74.0 is below minimum 0.75.0, a pre-release of 0.75.0
included) and is info when it cannot be compared (main-abc123, 0.78).
An unreachable server fails with Server unreachable: …, and an OAuth
profile the REST route refuses is info, in Python's words. The
/system/info body is read through Python's ServerInfo model, and one
it refuses (no name, a version that is no string) fails as the Python
CLI 0.42.0 fails it: Server answered, but the SDK could not read
/api/v1/system/info: KaguraClient.get_server_info: unexpected server
response for ServerInfo (…). The server may be newer than this SDK;
upgrading kagura-memory may help. Any other error from the probe, such
as a 429, fails the check with its own message (Rate limit exceeded (HTTP
429): …). A body that is not JSON is still Server unreachable: Invalid
response format: …, in the words of Python's json module (Expecting
value: line 1 column 1 (char 0)), since the body is read as json.loads
reads it. A credential
that does not resolve fails the auth section with Python's
Authentication could not be resolved: … and skips the server check
(info), as in Python. The credential is resolved as a bare client
resolves it, as in Python: KAGURA_API_KEY, then the OAuth profile
(--profile NAME, KAGURA_PROFILE or the default; an empty --profile
is the default), then .kagura.json. A selected profile that does not
exist fails the auth section once, with the resolution's message, and
with KAGURA_API_KEY set an expired or missing profile is no failure, as
in Python, whose warning is that the key shadows the profile. A resolved
MCP URL that is not HTTPS is a warning and the server is not contacted
(Server connectivity check skipped because the MCP URL is insecure),
whichever source it came from:
an OAuth profile's URL, KAGURA_MCP_URL, or the mcp_url of the
.kagura.json the credential resolved from (exit 0, as in Python). One
remaining difference: a plain-HTTP .kagura.json mcp_url that the
credential does not use, because KAGURA_API_KEY or an OAuth profile
brings its own URL, still fails the mcp section here (mcp_url is not
HTTPS: …, exit 1), since setup claude would refuse that URL; the
Python CLI's doctor never reads the file's mcp_url. --json
prints Python's shape, with details on every check ({} when there are
none). Any failed check exits 1.
Sign-in rate limit. memory-cloud v0.76.0 and later limit device sign-in
requests per client address. When the server refuses one with HTTP 429,
auth login says so and how many seconds to wait (from the Retry-After
header, 60 when it is missing or not a number of seconds), with the
server's reason, when it gives one, on the next line.
login() and authorizeDevice throw the same message as a
KaguraAuthError.
auth subcommands. Each takes only the options it reads, as in the
Python CLI: any other is refused with click's Error: No such option: --x
(exit 2), except --invite, which every subcommand but login refuses
with --invite applies only to 'auth login'. (exit 2, where click says
No such option: --invite), with a value or without and before
--help, as click's error would be, so that a token beginning with a
dash is not read as options. --help lists only a subcommand's own
options. auth status ends with the kagura-memory entry Claude Code uses in the
current directory and each entry it hides, in the Python CLI's words
(nothing when no scope defines one). auth list --json emits Python's fields per profile (profile,
default, user_email, workspace_name, workspace_id, server,
scope, expired, refreshable, expires_at), never a token, and []
when there is none, with non-ASCII escaped as \uXXXX, as Python's
json.dumps default writes it. auth list and auth status without
profiles exit 1. auth logout
revokes the access
token on the server before it deletes the profile, best effort: the
profile is deleted even when that fails, with Python's warning (with
--all, silently, as in Python). It notes when KAGURA_API_KEY is still
set. Unlike the Python CLI, it asks before removing anything unless
--yes (-y) is given, rather than refusing --all without it; a
logout that names no profile succeeds when nothing is stored, so
logout --yes stays idempotent in setup scripts; and --all with
--profile is a usage error (exit 2), where Python ignores --profile
and removes every profile. auth login with both --read-only and
--scope exits 1, as Python does. auth use NAME prints Python's line,
Default profile set to '<name>' (workspace '<workspace>')., and a note
when KAGURA_PROFILE is set and still overrides the new default; an
unknown name exits 1 and lists the stored profiles, and a missing or
extra argument is a usage error (exit 2).
workspace and the auth key commands (memory-cloud v0.42.0+)
manage a workspace's members, invitations and member API keys:
workspace member list|add|set-role|remove, workspace invite
create|list|revoke, and auth create-key|list-keys|revoke-key, which
mint, list and revoke a key for another member. The server takes only
the workspace owner's static API key here; an OAuth profile gets an
access-denied error that says so. As in the Python CLI, the workspace
comes from the same source as the key
(#115):
-w/--workspace when given, else the OAuth profile's workspace, else
context_id in the .kagura.json that holds the key. A KAGURA_API_KEY
key needs -w, since nothing binds it to a workspace:
KAGURA_API_KEY=<owner key> kagura-memory workspace member list -w
<workspace-uuid>. An empty or auto -w is refused (exit 1) rather
than falling back to the source's workspace. member remove and auth
revoke-key ask first, naming the workspace, unless --yes (-y).
create-key prints the key, and invite create the invitation URL,
once on stdout, after a warning on stderr. list-keys and invite list
never show either; their --json, like member list --json, prints
the Python model's fields in its order, null or false for what the
server left out, and drops any other. A record the model refuses (a
required field missing, a scalar, list or mapping of the wrong type)
exits 1 with its KaguraResponseError text; timestamps are printed as
the server wrote them. --role matches exactly, as
click's Choice does (Admin is refused). As in the Python CLI (0.41.1+):
a workspace that is not a UUID is refused before member remove or
revoke-key asks, and the question names it in canonical form; each
invite create -c must be a context UUID (exit 2, Invalid value for
'--context' / '-c': 'x' is not a valid context UUID., before anything
is read) and is sent in canonical form; a user id of ., .. or
nothing (USER_ID or --user) is refused (exit 2) before anything is
asked or sent; a context_id in .kagura.json that is not a string
reads as absent; and an invitation with no email prints -, as
invite list does. When more than one of these is wrong, the error
names them in declaration order (EMAIL, --role, -c,
--expires-days), where click names whichever comes first on the
command line.
measure record and measure series take the context as their
first argument, never from .kagura.json: an observation recorded in the
wrong context cannot be deleted. As in the Python CLI, a negative VALUE
needs no -- (measure record <context-id> pnl_usd -120). The price is
that record reads any option it does not declare as an argument: after
VALUE it is an extra argument, and in the CONTEXT_ID or METRIC slot it is
refused as No such option: <token>, even after --, as the Python CLI
refuses it. These errors name a --name=value token --name, as every
other command does, where click prints the whole token: the value may be
a key. --period and --agg match exactly (Week is refused).
When both are bad, the error names --period, where click names
whichever comes first. The result prints as the Python CLI prints the
model: its keys in its order, and unit null when there is none. A
whole-number float prints without its .0 (72 for 72.0).
Signing up with an invite
On a deployment that admits new accounts only by beta invite, pass the
invite to auth login, either as the bare token or as the
https://…/join/<token> link it arrived in:
npx kagura-memory auth login --invite <link-or-token>Without it, a new user who starts from auth login is refused at sign-up:
the approval page sends a signed-out visitor to the login page, which does
not carry an invite. With it, once the device code is issued, the CLI asks
the server how to present the invite (GET /api/v1/system/info, which
takes no credentials, allowed 5 seconds):
- The server supports the hand-off (memory-cloud 0.76.0 or later,
v0.76.0and build suffixes included; a pre-release of 0.76.0 such as0.76.0-rc1comes before it, as in the Python CLI): one link,<frontend>/join/<token>?return_to=%2Fdevice%3Fuser_code%3D<code>, that signs up with the invite and lands on the approval page with the code filled in. The browser opens that link. Below it, "If you land on the dashboard instead, approve here:" and the approval URL cover a user who is already signed in. - An older or unrecognised version, or the check failed: two steps, in
order. The plain
/join/<token>link, which the browser opens, then the approval URL, and how long the code stays valid. - The server does not take invites (a
featuresobject withoutbeta_invites: true: invites are turned off, or the server is older than memory-cloud 0.70.0, which has no/join): a one-line note, then the ordinary prompt. A body with nofeaturesobject says nothing about invites, so the version decides.
<frontend> is the device response's approval URL with its final
/device removed, so a frontend under a base path gets its /join beside
its /device. When that URL does not end in /device, the CLI does not
guess where /join lives: it takes the two steps, with your own link as
step 1, or tells you to open the invite you were sent when you gave a bare
token. It takes the two steps too when memory-cloud's /join would drop
the return_to (a path starting with //, as a frontend URL configured
with a trailing slash produces, or one holding a backslash or a control
character), which would otherwise leave the new user on the dashboard.
The invite is checked before any request. A malformed one exits 2 with
Error: Invalid value for '--invite': <reason>, the Python CLI's reason,
which never quotes the value. A pasted link must be written out in full,
https://<host>/…/join/<token> (a browser would repair https:/host/…;
the CLI does not), and must be HTTPS, as --server must (plain HTTP only
on localhost). A /join link is never built on a plain-HTTP frontend: the
token would travel in the clear. A link for a different server than the one
being logged into aborts before the server is asked about invites and
before the device code is polled, so no profile is written; the error
suggests logging in with --server instead. The token is never saved, in
credentials.json or anywhere else, and never appears in an error message:
it is printed only inside a link. Every other auth subcommand rejects
--invite with exit 2 rather than ignoring it. --no-browser works as it
does without an invite, and when the browser cannot be opened the CLI says
which link above to open by hand.
Logging in from TypeScript
login() runs the OAuth 2.0 Device Authorization Grant (RFC 8628) and
writes ~/.kagura/credentials.json in exactly the format the Python CLI
writes — no Python install needed, and the profile stays interchangeable
between both SDKs.
import { login, KaguraClient } from "kagura-memory";
const creds = await login({
onUserCode: ({ userCode, verificationUri, verificationUriComplete }) => {
console.log(`Open ${verificationUri} and enter ${userCode}`);
// Or, in a desktop app: shell.openExternal(verificationUriComplete)
},
});
console.log(`Logged in as ${creds.userEmail} (${creds.workspaceName})`);
// Subsequent clients pick the profile up automatically.
const client = new KaguraClient();There is no terminal IO and no browser launching inside the SDK —
onUserCode hands the code back and the host app decides how to show it.
Scope defaults to DEFAULT_SCOPE ("memory:read memory:write") — the same
default as the CLI's kagura auth login, and exactly what the server's
pre-registered kagura-cli client is seeded with. Pass
scope: READ_ONLY_SCOPE for the CLI's --read-only behaviour. The two SDKs
share the credentials file, so a profile should not end up with different
authority depending on which one created it.
The server is resolved as mcpUrl > KAGURA_MCP_URL > the public default,
matching the CLI's --server chain — so a self-hosted deployment is picked
up from the environment. Plain HTTP is rejected for non-loopback hosts: the
flow carries a bearer token.
Nothing is written unless the exchange succeeds, so a failed login never
disturbs an existing profile: a denial throws KaguraAuthDeniedError and an
unapproved expiry throws KaguraAuthExpiredError. A response with no
refresh_token warns and still persists — the Python CLI treats a
non-refreshable profile as a valid degraded state, and the shared file must
mean the same thing to both SDKs. Check the returned refreshToken if you
need to react.
For a custom flow (your own polling UI, multi-profile management), the
primitives are exported too: authorizeDevice, pollForToken,
refreshAccessToken, revokeToken, plus the credentials store
(loadCredentialsFile, updateProfile, setDefaultProfile,
deleteProfile, …).
For a new user holding a beta invite, buildInviteLink builds the same
sign-up-and-approve link the CLI's --invite prints. It takes the Python
SDK's build_invite_link arguments in the same order: the two approval
URLs from the device response, then the invite token. onUserCode is the
first point where both are known, so call it there. It is a pure function
and does not check the server version; that check is the CLI's own.
import { buildInviteLink, login } from "kagura-memory";
const token = "<token>"; // the last path segment of https://…/join/<token>
await login({
onUserCode: ({ verificationUri, verificationUriComplete }) => {
// null when /join cannot be placed: the approval URL does not end in
// /device, is plain HTTP off localhost, or is not a return_to that
// memory-cloud's /join keeps.
const link = buildInviteLink(verificationUri, verificationUriComplete, token);
if (link !== null) console.log(`Sign up and approve: ${link}`);
// A signed-in user, or a server older than memory-cloud 0.76.0, ends
// up on the dashboard instead; the pending code is approved here.
console.log(`Or approve at: ${verificationUriComplete}`);
},
});A malformed token throws KaguraAuthError, whose message never quotes it.
Refreshing
Clients auto-refresh as tokens near expiry, so most code never calls this.
refresh() covers what skew-driven rotation cannot:
import { refresh, READ_ONLY_SCOPE } from "kagura-memory";
// Forced rotation — e.g. after an upstream 401, when the token was
// revoked out-of-band and is not yet inside the skew window.
await refresh();
// Narrow an existing grant, no re-consent needed.
await refresh({ scope: READ_ONLY_SCOPE });
// Widening needs fresh consent, so it re-runs the device flow.
await refresh({
scope: "memory:read memory:write profile:read",
onUserCode: ({ userCode, verificationUri }) =>
console.log(`Open ${verificationUri} and enter ${userCode}`),
});The stored refresh token, scope, and workspace identity are preserved when
the server omits them. An expired refresh token throws
KaguraAuthExpiredError and leaves the stored profile untouched.
Callers holding their own provider can use KaguraOAuth directly — its
forceRefresh() is what refresh() builds on, including the
cross-process lock and the "another proces
