Arkiv lets you write entities to a chain and query them by attribute, like a database where every row is signed and expires on a schedule. The SDK is solid. The docs are decent. What was missing was the ten minutes before any of that matters: checking that your machine can talk to the network, that your key actually works, that the schema you are about to invent has not already been invented by three other people this week.
So I built arkiv, a terminal toolkit for the network. Nine commands, two runtime dependencies, and a test suite that runs against the built output. This post is about the decisions inside it, including the three bugs that only showed up when I took screenshots of real runs.
npm install -g arkiv-cli arkiv doctor What Arkiv actually is, in one paragraph
An entity is a payload of bytes plus up to 32 attributes. Attributes are typed (string, integer, boolean, address, key, bytes, decimal). Every entity has an owner (the address that signed it), an expiry (measured in blocks), and a schema name stored as an attribute called arkiv.schema. You query with a predicate language over those attributes. There is no full scan: a query needs a predicate or a schema filter, because the node cannot enumerate everything. Entities are not mutable in place; you create new ones and let the old ones expire.
That model is simple enough to explain in a paragraph and subtle enough to trip over. The subtleties are where a CLI earns its place.
Why a CLI and not another web app
Every Arkiv workflow starts in a terminal. You install an npm package, export a key, run a script with tsx, and read a stack trace. A web explorer is useful for looking at data you already know exists. It is useless for the question "why did my write fail", because the answer is usually local: a key with a trailing newline, a clock that is four minutes off, an RPC endpoint that quietly rate limits you.
doctor exists because I hit all three of those while building a Ghost Site on Rabdi, and each one presented as an unrelated error.
The shape of the tool
argv └─ args.ts ──► cli.ts ──► context.ts ──► arkiv.ts ──► SDK ──► RPC │ │ │ └─ env.ts (dotenv, secret registry, URL guard) │ ├─ commands/{init,doctor,query,entity,schema,create,watch,cost} │ ├─ values.ts (attribute types and sizing) │ └─ render.ts (tables, entity blocks) │ └─ ui.ts (colour, sanitize, redact) ──► stdout Two rules kept the design honest.
The first: every command takes a Context and an options object, prints, and returns Promise<void>. Commands do not throw user-facing strings and do not exit the process. cli.ts owns exit codes, which is why the tool is testable and why --json can fail with a parseable error instead of a stack trace.
The second: no command builds its own client. context.ts reads the env file, resolves the network, and hands back one client. That is what lets --network, --rpc and --env work everywhere without each command knowing about them.
Strict argument parsing, because a typo should not look like success
The parser is about 140 lines. It knows the flag table, the type of each flag, and whether it repeats. Anything unknown is a usage error:
$ arkiv query "kind = str('note')" --limt 5 error: unknown flag --limt Compare that to a permissive parser, where --limt 5 is ignored, the query runs with the default limit of 100, and you conclude the network is slow. Silent success on a typo is the most expensive bug class in a CLI.
Repeated flags accumulate through one put() function, which is what makes --set work:
const put = (key: string, value: FlagValue): void => { const previous = flags[key]; if (spec.repeat) { const list = Array.isArray(previous) ? previous : previous === undefined ? [] : [previous]; flags[key] = [...list, value]; return; } if (previous !== undefined) throw new UsageError(`--${key} was given more than once`); flags[key] = value; }; --set title=str:Hello --set kind=str:note becomes an array without a special case in the command, and --limit 5 --limit 10 is an error rather than a last-one-wins surprise.
Building queries with one guarded interpolation
The query command passes your predicate through as written, because writing predicates is the point. The --schema shortcut is different: it is a convenience, so it gets validated before it goes anywhere near the expression string.
export function buildExpression(options: QueryOptions): string { const parts: string[] = []; const raw = options.expression.trim(); if (raw) parts.push(`(${raw})`); if (options.schema) { if (!/^[A-Za-z0-9._/-]{1,64}$/.test(options.schema)) { throw new UsageError("--schema contains characters that are not allowed in a schema name"); } parts.push(`arkiv.schema = str('${options.schema}')`); } if (parts.length === 0) { throw new UsageError( 'give a filter expression, for example: arkiv query "arkiv.schema = str(\'my-app-v1.post\')"', ); } const expression = parts.join(" AND "); if (expression.length > 2000) throw new UsageError("expression is too long (2000 character limit)"); return expression; } There is no query builder abstraction on top of the predicate language. Translating a DSL into another DSL doubles the surface area and makes error messages worse: when the node rejects a predicate, you want to see the predicate it rejected, not the one your builder emitted.
Schema discovery when the node will not list schemas
There is no "list all schemas" call. A schema is just a string attribute, so the only way to know what exists is to read entities and look.
schema pages through the chain in key order, projects only the fields it needs ($key, $owner, $expiresAt, $attributeSchema, $attributes), and folds each entity into a map keyed by schema name. A 500 entity sample on Rabdi produced 78 distinct schemas, which tells you two things: people are building, and the naming is chaos. cookbook-rabdi-v1.note, arkiv-chunking/chunk/v2, eth-agentnet-mumbai26.rating, hexfence.poisoning.v1. Dots, slashes, hyphens, all in the same namespace.
That is why the linter is part of the command rather than a separate tool. Finding the schemas is half the job; the other half is telling you which ones will hurt you later.
The interesting bug lived here. Sampling is expensive, so schema <name> originally reused the sample and filtered it, which meant a sparse schema could return "no entities found" for a schema that plainly existed. Fixing it meant splitting the accumulation into a function both paths call:
function absorbEntity(schemas: Map<string, SchemaSummary>, entity: Entity): void { // shared by broad sample and targeted lookup so shape is computed identically } Now schema <name> issues a real query with arkiv.schema = str(name), and the sample path and the detail path agree because they run the same code over the results.
Writes you can inspect before they happen
create builds a schema at runtime from --set name=type:value flags. No schema file, no code generation, no drift between what you declared and what you wrote.
+----------------+--------+-------+ | attribute | type | bytes | +----------------+--------+-------+ | body | bytes | 2048 | | kind | str | 4 | | title | str | 5 | +----------------+--------+-------+ Before signing, the command checks attribute count against 32, string length against 128 bytes, payload size against 128 KiB, and lifetime against the protocol maximum. Then --dry-run prints the plan and stops. The distinction matters: a validation error is cheap, a rejected transaction costs you a round trip and a confused minute.
Lifetime parsing is a tiny thing that pays off daily:
arkiv create --schema notes/v1 --set title=str:Hello --expires 30d --dry-run 30d becomes blocks using the network's block timing, not a hardcoded 2 seconds, because a hardcoded block time is a lie the moment the chain changes.
Watching without subscriptions
The public RPC has no push stream. So watch polls, keeps a bounded window of matches ordered by key, and diffs the key sets between ticks:
const next = new Map(current.map((entity) => [entity.$key, entity])); for (const [key, entity] of next) { if (previous.has(key)) continue; out(`${green("+")} ${grey(stamp(entity))} ${summarise(entity)}`); } for (const [key, entity] of previous) { if (next.has(key)) continue; out(`${red("-")} ${grey(stamp(entity))} ${summarise(entity)}`); } previous = next; Key ordering is what makes the window stable: with a stable order, "the window is full" is a knowable condition, and the CLI can say so instead of silently dropping entities that fell outside the range. Any watcher built on polling has that failure mode. Most do not mention it.
The screenshots found three bugs
I captured every command with NO_COLOR=1, rendered each to a 2x PNG through a headless browser, and looked at the output as a reader would. Three things surfaced that the test suite and my own terminal had missed.
1. The SDK's rate-limit warning broke piped output. The SDK calls console.warn when you are on the public endpoint. In a terminal it scrolls past. Piped into a file or a --json consumer, it lands in the middle of the data. Fix: intercept the console channels, hold back only messages containing the rate-limit text, and replay the notice once at the end.
const capture = (...args: unknown[]) => { const text = args.map(String).join(" "); if (text.includes("heavily rate limited")) { capturedNotice = text.trim(); return; } originals.log(...args); }; 2. Node 26's localStorage warning did the same thing. An ExperimentalWarning from the SDK's dependency chain printed on every invocation. Suppressed at the entry point with a narrow match, not by filtering output everywhere, because warnings belong to stderr and --json consumers should never see them in the payload.
3. The redactor ate entity keys. The first version replaced any 64 hex character string with 0x[REDACTED], which is exactly the shape of an entity key, so arkiv entity printed key 0x[REDACTED] and became useless. The fix was to redact by context: a key-shaped string is only masked on a line that mentions key material (private key, secret, mnemonic, access key), while registered secrets are masked everywhere. Both directions now have a test.
That third one is the lesson I keep relearning: a security control that is too broad gets disabled by the user, and a security control that is too narrow gets bypassed. The useful version is the one that survives contact with real data.
What it does not do
- It does not generate wallets. You bring a key from the faucet or your own wallet, put it in
.env, and the CLI treats it as opaque input. - It does not store state. No cache, no config file beyond
.env, no database in your home directory. - It does not guess at GLM pricing.
costshows bytes, blocks, limit headroom and live gas price. The price of storage is the network's business. - It does not write anything without
create. There is no background process, no auto-repair, no telemetry.
Try it
npx arkiv-cli init my-app cd my-app cp .env.example .env npx arkiv-cli doctor npx arkiv-cli query "arkiv.schema = str('cookbook-rabdi-v1.note')" --limit 3 Reading works without a key. If you plan to use it daily, get an access key from the Arkiv Hub, because the public endpoint is shared and rate limited, and doctor will tell you so rather than let you blame the network.
Code & more: https://www.dailybuild.xyz/project/315-arkiv-cli













