@nidl/client
v0.7.0
Published
Drive a running nidl core from a Node program, over its local socket or its remote port.
Readme
@nidl/client
Drive a running nidl core from a Node program, over its local socket, or over HTTPS to its remote port on another machine.
import { connect } from "@nidl/client";
const nidl = await connect(); // the core serving ~/.nidl; or connect({ dataDir })
if (nidl.has("targets.list")) {
const { targets } = await nidl.command("targets.list");
}- It never starts a core. When none is running,
connect()fails with codenot_runningand says how to start one:nidl serve --data-dir ~/.nidl, or open the nidl app. - It checks the socket's folder first: each folder on the way is yours or root's and not
writable by others, the control folder is
0700, the core's record is a private file describing this folder's core, and the socket is the one that record names. A check that fails throws codeunsafe_folder. Node cannot ask the operating system who is at the other end of a socket, so this library cannot check the peer's user; the Python and Rust libraries can. - It is an app, unless you say it's the owner. By default it connects as the
appclient kind: it can read, draft, preview and apply, but the owner's commands, such as unlocking credentials or issuing an app token, are refused withrequires_owner_action. A program that embeds nidl and commands it passes{ owner: true }(see below). - Command names are typed.
command()takes aCommandName, one ofCOMMAND_NAMES(the commands an app may run), or anOwnerCommandName, one ofOWNER_COMMAND_NAMES, both generated from the core's registry. A misspelled name,pools.lendor a command that takes a secret is a type error. Call a command a newer core offers with a cast:nidl.command("its.name" as CommandName). Results are plain JSON. - Each call is one connection: the folder is checked, the core says hello, the command runs, the connection closes.
Errors are NidlErrors with a code for programs, a message for people, and an outcome:
not_started, or unknown when the connection was lost after the command was sent, since it may
have run.
| Code | Meaning |
| --- | --- |
| not_running | no core serves this folder |
| unsafe_folder | the socket's folder failed a check |
| not_offered | this core does not have that command; update nidl |
| version_conflict | the core speaks another protocol version |
| path_too_long | the socket's path is longer than the platform allows |
| timeout | no answer within timeoutMs (10 seconds by default) |
| protocol_error | the core's reply broke the protocol |
| anything else | the core's own refusal, such as not_found or requires_owner_action |
A core on another machine
When a machine's owner has turned on nidl's remote port there (nidl remote enable) and issued you
a remote token (nidl remote-tokens issue), connect to that core by its URL, https://HOST:PORT:
const nidl = await connect({
url: "https://nidl.example:8443",
token: process.env.NIDL_TOKEN,
fingerprint: "sha256:…", // as `nidl remote status` shows it on that machine
});- The certificate is checked before the token is sent. With a fingerprint, only nidl's own
certificate with that fingerprint is accepted, as SSH remembers a host's key; the handshake still
proves the server holds its key. Without one, the certificate must be one Node trusts for the URL's
host, as when the owner serves a certificate of their own. Anything else fails with
untrusted_certificate, and nothing is sent. - The token's scope decides what runs. A
readtoken lists and reads. Anoperatetoken also drafts targets, refreshes catalogs and readings, and previews and applies routing. Neither runs credential commands or starts paid model checks: those stay with the owner at the machine (requires_owner_action). nidl.identityhas norootorinstanceNoncefor a remote core, which does not say what names its files; it says the token'sscope.- Each call is one HTTPS connection to the remote port, closed after the answer.
The remote codes, beside those above: unauthorized (the token is unknown, revoked or expired),
untrusted_certificate (the certificate is not the one expected; nothing was sent), throttled
(too many failed attempts from this address; wait a minute), invalid_input (the URL, token or
fingerprint is malformed, or the command is over 1 MiB), unavailable (the machine could not be
reached, or its core is busy or not serving), and unsupported (that nidl's remote port takes no
commands; update it there).
Lending your project's key, and reporting readings
Another program can use nidl for its own accounts once the owner has made it a project there
(nidl projects create), with a borrowed account for each API key it holds
(nidl projects add-account, mode borrowed) and an API target with an app token
(nidl targets create, nidl app-tokens issue). Your program lends nidl its key for as long as it
wants requests to the target's endpoint, /h/<target>/v1/…, routed with it, and takes it back:
An endpoint you describe, an Anthropic- or OpenAI-compatible borrowed account, is lent its address and its key, if it has one, with lendEndpoint(accountId, { baseUrl, apiKey? }, { appToken }) (CUE-1122). The core checks the address as it checks a held endpoint account's.
const lease = await nidl.lend(accountId, apiKey, { appToken: process.env.NIDL_APP_TOKEN, expiresInSeconds: 3600 });
// ... calls to the target's endpoint route with the key while the lease lives ...
await nidl.withdraw(accountId);- The key travels in the socket's secret frame, never in a command's arguments, so
command("pools.lend")is refused withinvalid_input. nidl keeps it in memory only, never on disk, for 60 seconds to 12 hours (an hour unless you say) and never past the app token. It is dropped when the lease expires, when you withdraw it, when the owner revokes the token or removes its target, and when the core stops. Lend again to renew it. - The app token proves the account is your project's. A token of another project's target, a
revoked or expired one, or none, is refused with
not_authorized. - On this machine only.
lendandwithdrawon a remote core fail withunsupportedbefore anything is sent. - The lease is
{accountId, lentAt, expiresAt, lentBy},lentBybeing your app token's id. It never holds the key.
A JavaScript string cannot be wiped; the frame's own bytes are overwritten once the socket has taken them.
An observed account is one nidl never holds a key for, a subscription for instance, whose quota your program reports so the owner sees it beside their own:
await nidl.observe(accountId, [{ key: "five_hour", percent: 37.5, resetsAt: "2026-10-21T15:00:00Z" }]);At most 16 windows, each a key, a percent from 0 to 100, and optionally a label and a
resetsAt, captured within the last 24 hours (now, unless you say). On a remote core this needs an
operate token bound to your project (nidl remote-tokens issue with its project).
Embedding nidl: the owner's connection
A program that runs its own nidl core, and decides itself when an account is used, starts one and connects as the folder's owner:
import { connect, startCore } from "@nidl/client";
const core = await startCore({ env: { ...process.env, NIDL_VAULT_HOLDER: "folder" } });
const nidl = await connect({ dataDir: core.dataDir, owner: true });
await nidl.setPassphrase(passphraseFromYourVault); // a new folder; later: unlockPassphrase()
await nidl.command("projects.create", { name: "my-app", label: "My app" });
await nidl.connectAccount(
{ providerId: "anthropic-api", label: "Team key", proof: "proven" },
{ apiKey: keyFromYourVault },
);
const { appToken, token } = await nidl.command("app_tokens.issue", { targetId, name: "my-app" });startCore({ dataDir, directory, env, executable, timeoutMs })starts the core@nidl/clicarries (orexecutable), with no account and no model-list refresh, and answers itsdataDirandstop(). WithoutdataDirit makes a new private folder indirectory(the temporary or home folder by default), whichstop()removes; with one, it serves that folder and keeps it. The core stops when this process ends. A core that won't start says why, in its own words.{ owner: true }says hello as thecliclient kind. The core admits it only from a program running as the core's own user, and only on this machine, never through the remote port.- It runs the owner's commands too,
OWNER_COMMAND_NAMES:credentials.*,projects.*,app_tokens.*, the store's and the remote port's. - A secret never goes in a command's arguments.
commandWithSecret(name, args, secret)sends it in the socket's secret frame, forSECRET_COMMAND_NAMES:accounts.connectandaccounts.reconnect(the account's credential JSON),credentials.set_passphraseandcredentials.unlock_passphrase(the passphrase), and the store's logins.connectAccount(),setPassphrase()andunlockPassphrase()name the common ones.command()refuses these, and an app's connection refuses them before anything is sent. - A vault key on a Mac without the Keychain: start the core with
NIDL_VAULT_HOLDER=folderto unlock with a passphrase you send, or withNIDL_VAULT_KEY_FILEnaming a key file (64 lowercase hexadecimal characters, a file only you can change). Without either, a Mac folder keeps its key in the login Keychain behind Touch ID. On Linux, a key file, else a passphrase.
Handing a session its account
A target applied with hand-over (docs/specs/nidl-hand-over-mode.md) gives a session its
account's own credential instead of routing it. Start the harness with what handOver() answers:
const handed = await nidl.handOver(targetId, { dialect: "claude-code", appToken });
const env = handOverEnvironment(handed); // from process.env, without nidl's routing
spawn("claude", [...handed.args, "-p", "hello"], { env }); // Codex: [...yours, ...handed.args]
// Before handed.expiresAtMs, the same account's next token:
const next = await nidl.renewHandOver(handed.handoverId, handed.renewal, { appToken });dialectnames the harness on an API target:claude-code(Messages) orcodex(Responses).accountIdchooses the account; without it the target chooses, as Auto would for a new session.- The app token and the renewal secret go in the socket's secret frame;
command()refuseshandover.issueandhandover.renew. An app is handed its project's own accounts, and the owner's subscriptions shared with its project.handOvers(targetId)reads the records, at the machine. handOverEnvironment(handed, base?)builds the session's environment as nidl's launcher does:unsetand everyNIDL_variable removed, thex-nidl-tokenheader line dropped (your other headers stay), thenset. Give the session a freshCLAUDE_CONFIG_DIR(orCODEX_HOME) when its own settings files might set a login or a proxy: nidl's launcher refuses those, a library can't.- For Codex,
argsare all-cpairs, the model's too (-c model="…"), socodex execandcodex app-servertake them alike. A ChatGPT plan is handed only with a model Codex 0.162.1 knows; on a newer Codex, check that yours still knows it. Run Codex 0.162.1 or later to hand it a plan, and keep the reply's-cpairs as they are: 0.157.0 passes the token to every command it runs, and 0.162.1 does too without the twoshell_environment_policypairs. Codex'snotifyprogram, when one is set, gets the token too. On Linux, the session's own environment, and so the token, can be read through/procby any process of the same user outside its sandbox: run the harness's commands in its sandbox, or each session under its own user. minLifetimeMinutes(15 to 1440, onhandOverandrenewHandOver) asks for a token with at least that long left, for a turn that runs long: nidl renews first, and refuses, saying why, when even a fresh token falls short. A ChatGPT plan's token lasts about an hour, so about 50 is its ceiling; a Claude subscription's lasts hours.- Never a refresh token. A key handed over (the target's keys on) works until its provider revokes it.
In a Trigger.dev task
A task can run its own nidl core in its container, from a task vault you export at your
machine (nidl task-vault export) and give the task as two secrets, NIDL_TASK_VAULT and
NIDL_TASK_VAULT_KEY:
import { startTaskCore } from "@nidl/client";
const nidl = await startTaskCore(); // once per process; a warm worker reuses it
process.env.ANTHROPIC_BASE_URL = nidl.baseUrl; // http://127.0.0.1:<port>/h/<target>
process.env.ANTHROPIC_API_KEY = nidl.appToken;- It removes both secrets from
process.envbefore anything else runs, writes them to private files, opens the task vault, serves it with--stop-with-parentand unlocks it with its key file. It needs@nidl/clibeside it (orexecutable), both kept out of the bundle (build.external). - Its folder is made in the temporary folder, or in the home folder when someone else may write to
the temporary one, as anyone may to
/tmp;directorynames another. A start that fails removes what it wrote and can be called again;stop()removes the folder once the core has stopped. - Linux only, where Trigger.dev deploys run. Otherwise a task reaches the hub with an app token.
- Codes:
not_configured(no secrets),not_installed(no@nidl/cli),timeout,unavailable, or the core's own, such asinvalid_inputfor a vault that does not open.
The whole path, the hub's included, is in docs/integrations/trigger-dev.md.
Long-running commands return an operation id: poll it with nidl.command("operations.get", { operationId }).
Results are JavaScript values, so a whole number above 2^53 loses precision, as it does in any
JSON.parse. nidl's results do not carry such numbers today.
The protocol is described in
docs/protocol/nidl-core-protocol.md.
Node 22 or later, on macOS or Linux. No dependencies: the remote port uses Node's own tls and http.
