npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@akai-workflow-builder/cli-sdk

v0.1.5

Published

Authoring SDK for atomic, agent-executable CLIs and tools.

Readme

@akai-workflow-builder/cli-sdk

Author atomic, agent-executable CLIs in TypeScript.

A small, opinionated SDK for defining CLIs that an agent runtime invokes. Each tool represents one atomic operation — typically a single HTTP call, but a tool can wrap any single observable operation. The SDK gives you a sandboxed fetch, scoped secrets, typed input + output schemas, and a per-tool egress allowlist. The host runtime handles input validation, secret scoping, network sandboxing, and the human-in-the-loop approval gate for writes.

Status: pre-v0.1. The authoring surface (tool, defineCLI, buildCtx, ctx.fetch, ctx.secrets, ctx.properties) is stable.


Installation

npm install @akai-workflow-builder/cli-sdk zod

Requirements

| | | |---|---| | Node | ≥ 24 | | TypeScript | ≥ 5.0 (uses const type parameters for literal-key inference) | | zod | ^4.4.1 (peer dependency) | | tsconfig.json | needs Response / RequestInit / AbortSignal. Either add "DOM" to your existing lib entries (e.g. "lib": ["ES2023", "DOM"]) or include @types/node@^18+ |


Quickstart

import { tool, defineCLI } from '@akai-workflow-builder/cli-sdk';
import { z } from 'zod';

// Define the response shape once; reuse for `jsonOutput` and runtime parsing.
const Response = z.object({
  user: z.object({ id: z.number(), email: z.string() }),
});

const usersMe = tool({
  name: 'Authenticated user',
  description: 'Return the authenticated user.',
  jsonOutput: Response,                          // input defaults to z.object({}) when omitted
  options: {
    network: { egress: 'zendesk.com' },
    secretKeys: ['ZENDESK_SUBDOMAIN', 'ZENDESK_API_TOKEN'],
    isReadonly: true,
  },
  handler: async ({ ctx, isJson }) => {
    const sub = ctx.secrets.ZENDESK_SUBDOMAIN!;
    const tok = ctx.secrets.ZENDESK_API_TOKEN!;
    // Quickstart trusts `sub` is a workspace shortname ("acme"); the worked
    // example validates it before constructing the URL.
    const res = await ctx.fetch(`https://${sub}.zendesk.com/api/v2/users/me.json`, {
      signal: ctx.signal,
      headers: { Authorization: `Bearer ${tok}` },
    });
    if (!res.ok) throw new Error(`fetch authenticated user failed: ${res.status}`);
    // Parse, don't cast — strips unknown fields, throws if the shape changes.
    const body = Response.parse(await res.json());
    return isJson ? body : `User #${body.user.id} <${body.user.email}>`;
  },
});

export default defineCLI({
  id: 'zendesk',
  name: 'Zendesk Support',
  summary: 'Read tickets, users, and search results from Zendesk Support.',
  description:
    'Connect to a Zendesk Support workspace via OAuth bearer token. Read-only surface: fetch the authenticated user, fetch tickets and users by id, list tickets, and run search queries.',
  secrets: [
    { key: 'ZENDESK_SUBDOMAIN', name: 'Workspace subdomain', description: '...', required: true },
    { key: 'ZENDESK_API_TOKEN', name: 'API access token',    description: '...', required: true },
  ],
  tools: {
    'users.me': usersMe,
  },
});

A full worked example with five read-only tools and a smoke runner lives in the repository under examples/zendesk/ (not bundled in the published package; see the source tree).

Defining a connection

A connection is the umbrella identity that groups CLIs sharing one auth surface — e.g. google groups Gmail and Sheets. Declare it with defineConnection, symmetric to defineCLI:

import { defineConnection } from '@akai-workflow-builder/cli-sdk';

export default defineConnection({
  slug: 'google',                 // groups CLIs that set connectionSlug: 'google'
  name: 'Google Workspace',       // umbrella display name
  vendor: 'Google',
  blurb: 'Gmail, Sheets, Docs, Drive and Calendar in one connection.',
  longDesc: 'Connect once with OAuth; every Google CLI shares the authorization.',
  authKind: 'oauth2',
  oauth: { /* see below */ },
});

CLIs link to it through the existing defineCLI — set connectionSlug:

const gmail  = defineCLI({ id: 'gmail',  connectionSlug: 'google', summary: '…', tools: { … } });
const gsheet = defineCLI({ id: 'gsheet', connectionSlug: 'google', summary: '…', tools: { … } });

When to declare one: only when ≥2 CLIs share a slug and need a single umbrella identity. A single-CLI connection needs no defineConnection — its identity comes from that CLI's own metadata (pdf, sandbox and friends are unchanged).

defineConnection owns one concern: identity. It does not own tool sourcing, OAuth secret glue, or UI cosmetics — those split by source:

| Concern | Source | |---|---| | Identity (name / blurb / longDesc / instructions / vendor / authKind) | defineConnection | | OAuth shape (authorize/token URLs, scopes, pkce, params, refresh, admin fields) | defineConnection.oauth | | OAuth secret VALUES, token storage, encryption, refresh hooks | akai-app, keyed by slug | | UI cosmetics (hue / mark / logo) | akai-app, keyed by slug |

A complete runnable example is in examples/googledefineConnection plus two CLIs (gmail, gsheet) grouped under it.

OAuth connections

When authKind is oauth2, declare an oauth block. The manifest declares shape; akai-app owns values:

oauth: {
  authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
  tokenUrl: 'https://oauth2.googleapis.com/token',
  scopes: [                                             // OAuth authorize scopes (≥1); distinct from per-CLI vendorScopes
    'https://mail.google.com/',
    'https://www.googleapis.com/auth/drive',
  ],
  pkce: true,                                           // Google requires PKCE; defaults to false
  tokenEndpointAuthMethod: 'client_secret_post',        // default 'client_secret_basic'
  extraAuthorizeParams: { access_type: 'offline', prompt: 'consent' },
  refreshStrategy: 'cron',                              // 'cron' (background refresh) | 'jit' (refresh on next use); default 'cron'
  adminFields: [
    { key: 'GMAIL_CLIENT_ID',     label: 'Client ID' },
    { key: 'GMAIL_CLIENT_SECRET', label: 'Client Secret', secret: true },
  ],
}
  • scopes is the connection-level OAuth authorize scope set (≥1) the engine joins onto the authorize URL — distinct from a CLI's per-tool vendorScopes.
  • Set pkce when the provider requires it; set extraAuthorizeParams for static authorize params (Google's access_type: 'offline' to get a refresh token); leave refreshStrategy at the default cron (a background job refreshes the token before it expires) or set jit to refresh just-in-time on the next use.
  • adminFields declares which OAuth-app credentials an admin must enter (env-style key + UI label). The VALUES are stored encrypted in akai-app, never in the manifest or the per-tenant image.
  • Mark a field secret: true so akai-app masks the input in the admin form and redacts it from logs.

See the connection-identity and define-connection-oauth migration specs (akai-app app/connections/migration-specs/) for per-field guidance.


Core concepts

Atomic tools

Every tool() represents one atomic operation — typically a single HTTP call, but it can wrap any single observable operation (a local compute, a file read, a DB query). Composition is the planner's responsibility — agents plan over the atomic tools the runtime exposes; the SDK is intentionally not the place to chain steps.

Three consequences:

  • Per-tool permissions are meaningful — one tool = one observable side effect = one approval surface.
  • Schemas stay tight — each tool documents one operation, not a workflow.
  • No hidden side effects — what the schema describes is what executes.

The SDK doesn't statically enforce step count — atomicity is a design guideline. If a logical step needs three calls, ship three tools and let the planner compose them.

defineCLI()

A CLI is a named bundle of tools sharing one secret-declaration set:

defineCLI({
  id: 'zendesk',
  name: 'Zendesk Support',
  summary: 'Read tickets, users, and search results from Zendesk Support.',
  description: 'Connect to a Zendesk Support workspace via OAuth bearer token …',
  secrets: [/* ... */],
  tools: {
    'users.me':     usersMe,
    'tickets.get':  getTicket,
    'tickets.list': listTickets,
  },
});

Required

| Field | Role | |---|---| | id | Machine slug. Used for wire ids (<id>.<toolKey>) and the x-akai-cli header. Pattern: ^[a-z][a-z0-9_-]*$. | | name | Human display label. | | summary | One-sentence short label. | | tools | Map of tool() results. Keys follow the kebab + dotted convention below. |

Optional

| Field | Role | |---|---| | vendor | Brand/company. Defaults to name. Useful when display ("JPMorgan Access Portal") differs from vendor ("JPMorgan"). | | description | Long-form description. Consumers decide how to render when omitted. | | instructions | Ordered setup steps shown to operators. | | secrets | Array of SecretDecl (the keys tools reference). | | properties | Array of PropertyDecl for non-sensitive tenant config (base URLs, region codes). Distinct from secrets[]. |

defineCLI() cross-validates every secretKeys and propertyKeys reference against the CLI-level declarations. Mismatches throw AkaiSpecError at module load — no silent failures.

Tool keys

Tool keys are kebab-case, optionally dot-namespaced for grouping:

chat-post-message
users.me
tickets.search-by-query
activations.contract-benefit.mark-enrolled-ahead-of-ingest

Each dot-separated segment matches [a-zA-Z][a-zA-Z0-9_]*(-[a-zA-Z0-9_]+)* — first char a letter, hyphens must be followed by at least one word char (so --, leading/trailing -, and empty segments reject). Wire ids are <cliId>.<toolKey>.

Underscores and camelCase are still accepted so existing tools keep working, but new tools should be kebab to match the convention in the akai-cli-tools repo.

The ctx contract

Tool handlers receive a frozen ctx with these members:

interface ToolCtx<S extends string, P extends string> {
  fetch:      (url: string, init?: RequestInit) => Promise<Response>;
  secrets:    Readonly<Record<S, string | undefined>>;
  properties: Readonly<Record<P, string | undefined>>;
  logger:     ToolLogger;
  signal:     AbortSignal;
  workdir:    string;
  safePath:   (userPath: string) => string;
  readFile:   (value: string) => Promise<Buffer>;
}

What you get

  • fetch — sandboxed fetch bound to the tool's egress allowlist.
  • secrets — only the secret keys the tool declared in secretKeys. Other workspace secrets are inaccessible.
  • properties — only the property keys the tool declared in propertyKeys. Non-sensitive tenant config (base URL, region) lives here, not in secrets.
  • logger — structured logger; writes to stderr. stdout is reserved for the tool's result.
  • signal — request AbortSignal. Forward it to ctx.fetch for cancellation and timeouts.
  • workdir — writable scratch directory scoped to this invocation.
  • safePath — resolve an agent-supplied path under the tool's allowed roots (default: workdir + os.tmpdir()); strips file://, follows symlinks on every existing parent segment, and throws AkaiPathError on escape.
  • readFile — read a local path (resolved via safePath) or an http(s):// URL; pairs with filePath() from the SDK so handlers transparently support cross-pod file delivery.

What ctx deliberately omits

  • No process.env accessor — secrets flow through ctx.secrets so the runtime can scope and audit them.
  • No fs, child_process, or worker_threads handles — ctx doesn't pre-wire them. The SDK does not sandbox the Node runtime; if a handler imports those modules directly, the host is responsible for restricting that out of band.
  • No handle to invoke sibling tools — composition is the planner's job.

Secrets — declarations vs references

Secrets are declared once at the CLI level and referenced by key on each tool that needs them:

defineCLI({
  secrets: [
    { key: 'ZENDESK_API_TOKEN', name: 'API token', description: '...', required: true },
  ],
  tools: {
    getTicket: tool({
      options: {
        secretKeys: ['ZENDESK_API_TOKEN'],
        /* ... */
      },
      /* ... */
    }),
  },
});

| | Field | Type | Role | |---|---|---|---| | CLI | secrets | SecretDecl[] | Operator-facing declarations | | Tool | options.secretKeys | string[] | Per-tool subset | | Runtime | ctx.secrets | Readonly<Record<S, string \| undefined>> | What the handler reads |

When building ctx, the runtime walks the tool's declared secretKeys. Each key must be declared in the CLI's secrets[]; an undeclared reference throws AkaiSpecError at module load. At invocation, a required: true secret with no value throws AkaiSecretError. Optional secrets that the caller didn't supply appear on ctx.secrets as undefined so handlers can branch.

ctx.secrets[K] is typed string | undefined for all K — optional secrets may be absent, so handlers narrow before use. Even when required: true, the SDK can't statically prove the runtime body shipped the value, so the type stays union.

Properties — non-sensitive tenant config

Tenant-specific values that aren't credentials live in properties[], not secrets[]. Examples: a workspace subdomain, a self-hosted base URL, a region code. The host runtime stores these without the encryption-at-rest treatment applied to secrets and may surface them in admin UIs unmasked.

defineCLI({
  secrets:    [{ key: 'JIRA_API_TOKEN', name: 'API token',     description: '...', required: true }],
  properties: [{ key: 'JIRA_BASE_URL',  name: 'Workspace URL', description: '...', required: true }],
  tools: {
    getIssue: tool({
      options: {
        secretKeys:   ['JIRA_API_TOKEN'],
        propertyKeys: ['JIRA_BASE_URL'],
        network:      { egress: { from: 'property:JIRA_BASE_URL' } },
        isReadonly:   true,
      },
      handler: async ({ ctx }) => {
        const base = ctx.properties.JIRA_BASE_URL!;
        const tok  = ctx.secrets.JIRA_API_TOKEN!;
        return ctx.fetch(`https://${base}/rest/api/3/issue/...`, {
          headers: { Authorization: `Bearer ${tok}` },
        });
      },
      /* ... */
    }),
  },
});

| | Field | Type | Role | |---|---|---|---| | CLI | properties | PropertyDecl[] | Operator-facing declarations | | Tool | options.propertyKeys | string[] | Per-tool subset | | Runtime | ctx.properties | Readonly<Record<P, string \| undefined>> | What the handler reads |

AkaiPropertyError is thrown when a required: true property is missing at invocation time. Same lifecycle as AkaiSecretError, distinct class because the failure source is conceptually different.

Dynamic egress (multi-tenant connectors)

For connectors where the host varies per tenant (Jira Cloud, Zendesk, Looker self-hosted, GHES, Salesforce), declare the host as a property and reference it from egress:

options: {
  propertyKeys: ['JIRA_BASE_URL'],
  network:      { egress: { from: 'property:JIRA_BASE_URL' } },
}

defineCLI() cross-checks that the referenced key is declared in properties[] AND included in the tool's propertyKeys. The host runtime resolves the reference per invocation; SDK-side buildCtx throws AkaiSpecError to enforce that the host has resolved the egress before constructing ctx.

Egress allowlist

Every tool declares its allowed outbound hosts:

options: {
  network: { egress: 'api.example.com' },                  // single host (shorthand)
  // or:    { egress: ['api.example.com', 'cdn.example.com'] },
}

ctx.fetch enforces, in order:

  • Protocol — HTTPS-only in production. HTTP allowed only when NODE_ENV === 'development'.
  • Host — exact-or-suffix-with-dot. api.example.com matches itself and *.api.example.com; evil.api.example.com.attacker.tld does not.
  • DNS preflight — hostname resolved before the request. In production, addresses in private (RFC 1918), loopback, link-local, or unique-local IPv6 ranges are refused (IPv4, IPv6, v4-mapped, and canonicalized forms). In development the private-IP refusal is skipped so handlers can target local services.
  • Redirectsredirect: 'error' is pinned on every request, non-overridable. If the target responds with a 3xx, fetch itself rejects; the SDK never follows redirects, so a Location: to a public host cannot smuggle the request past the egress check.
  • Identity headersuser-agent, x-akai-cli, x-akai-tool, x-akai-request-id are stamped on every request. x-akai-tenant is stamped only when the host runtime passes a tenant id to buildCtx; otherwise it is removed even if a handler tried to set it. Handler-supplied values for any of these are always overwritten.

Protocol, host, and DNS-preflight failures reject with AkaiEgressError before any network request is made. Redirect failures surface as a fetch rejection after the initial response — same outcome (no follow-through), different error type.

Spec validation also rejects egress hosts that don't pass a syntactic DNS-hostname check — localhost (single label), IP literals, hosts with spaces or slashes — so they can't be declared in the first place. The check is shape-only; it doesn't verify the name is publicly resolvable, so internal DNS zones still pass the spec and are then handled at DNS-preflight time.

isReadonly

Every tool must declare whether it mutates remote state:

options: {
  isReadonly: true,   // reads, queries — no approval gate
  // isReadonly: false, // writes — runtime pauses for human approval
}

Two roles:

  1. Planner hint — exposed in the tool listing so plan UIs render write steps distinctly.
  2. Runtime gate — write tools cannot execute unless a confirmation flag is set, which the runtime flips only after the approval flow returns approved.

No default. The SDK throws on omission — silent defaults would risk marking writes as readonly. When in doubt, choose false — false-positives cost one approval click; false-negatives cause silent mutation.

Output mode: JSON vs text

Each handler receives an isJson: boolean flag. The caller chooses format=json or format=text; the handler returns either shape:

handler: async ({ input, ctx, isJson }) => {
  const body = await fetchTheThing(/* ... */);
  return isJson ? body : `Thing #${body.id} (${body.status})`;
}

When jsonOutput is declared, the handler return type is string | ZInfer<jsonOutput>. When jsonOutput is omitted, the handler always returns a string.


Worked example: Zendesk

The repository's examples/zendesk/ folder ships a five-tool, read-only Zendesk Support connector (not bundled in the published package):

| Tool | Endpoint | Demonstrates | |---|---|---| | users.me | GET /users/me | Auth probe; simplest tool | | users.get | GET /users/{id} | Path parameter | | tickets.get | GET /tickets/{id} | Path parameter, 404 mapped to typed error | | tickets.list | GET /tickets | Cursor pagination | | tickets.search | GET /search | Query composition, 422 surfaced |

Each tool branches on isJson and uses the structured logger. The folder includes a smoke runner that mirrors the runtime's per-invocation dispatch (registry → buildCtx() → handler) so you can hit a real workspace from your terminal.


Errors

| Class | When | |---|---| | AkaiSpecError | Invalid tool() / defineCLI() spec — thrown at module load | | AkaiInputError | Tool input fails the declared zod schema — thrown by the runtime when validating the request body | | AkaiSecretError | A required: true secret is missing at invocation time — thrown by buildCtx | | AkaiPropertyError| A required: true property is missing at invocation time — thrown by buildCtx | | AkaiEgressError | ctx.fetch rejected the target | | AkaiToolError | A handler threw a typed, caller-facing failure — the host maps kind to an HTTP status and surfaces the message verbatim |

Every error extends AkaiError; the runtime maps each to a structured error envelope.

Typed tool errors

Throw AkaiToolError from a handler to give an expected failure a stable classification and author-vetted wording. The host runtime brand-checks the thrown value by property access on its shape — { name: 'AkaiToolError', kind, message, meta? } (it reads message via property access, not JSON serialization, since Error.message is non-enumerable) — with no SDK dependency, maps kind to an HTTP status, and returns message to the caller verbatim. Keep messages secret-free.

import { AkaiToolError } from '@akai-workflow-builder/cli-sdk';

const res = await ctx.fetch(`https://api.example.com/tickets/${id}`);
if (res.status === 404) throw AkaiToolError.notFound(`No ticket ${id}`, { id });
if (res.status === 429) throw AkaiToolError.rateLimited('Slow down', { retryAfterMs: 1000 });

Factories — each sets the matching kind:

| Factory | kind | Host status | |---|---|---| | AkaiToolError.notFound(msg, meta?) | not_found | 404 | | AkaiToolError.forbidden(msg, meta?) | forbidden | 403 | | AkaiToolError.authExpired(msg, meta?) | auth_expired | 401 | | AkaiToolError.invalidInput(msg, meta?) | invalid_input | 422 | | AkaiToolError.conflict(msg, meta?) | conflict | 409 | | AkaiToolError.rateLimited(msg, meta?) | rate_limited | 429 (retryable) | | AkaiToolError.unavailable(msg, meta?) | unavailable | 503 (retryable) |

Untyped throws are classified generically (a 500 with a derived message).


Troubleshooting

Cannot find name 'Response'tsconfig.json is missing web-platform types. Add "DOM" to your existing lib entries (e.g. "lib": ["ES2023", "DOM"]) — setting lib overrides the target's defaults, so include both — or use @types/node@^18+.

AkaiSpecError: undeclared secret 'X' — a secretKeys entry references a key that isn't in defineCLI({ secrets: [...] }).

AkaiSpecError: undeclared property 'X' — a propertyKeys entry references a key that isn't in defineCLI({ properties: [...] }).

AkaiSpecError: invalid id "..." — CLI id must be a lowercase slug matching ^[a-z][a-z0-9_-]*$. The display label lives in name.

AkaiSpecError: invalid egress host for localhost / 127.0.0.1tool() rejects IP literals and single-label hostnames (including localhost) at spec time, before ctx.fetch ever runs. For local development, use a tunnel (ngrok, Cloudflare Tunnel) that resolves to a public hostname.

AkaiEgressError against an unallowed host — runtime egress check failed. Add the host to options.network.egress (matched exact-or-suffix-with-dot).

Type error: handler return doesn't match jsonOutput — when jsonOutput is declared, the handler return type is string | <structured shape>. Branch on isJson.


Changelog

Unreleased

  • Add defineConnection — declare a connection's umbrella identity (slug, name, vendor, blurb, longDesc, instructions, authKind) plus its oauth shape. Returns a frozen, WeakSet-branded ConnectionDef. Symmetric to defineCLI.
  • Add the oauth block: authorizeUrl, tokenUrl, scopes, pkce, tokenEndpointAuthMethod, extraAuthorizeParams, refreshStrategy, and adminFields (the OAuth-app credentials an admin enters; values stay in akai-app). Validated for valid URLs, non-empty scopes, env-style admin keys, and adminFields presence when authKind is oauth2.
  • New exports: defineConnection, isAkaiConnection, AkaiConnectionError, and types ConnectionSpec, ConnectionDef, ConnectionOAuthConfig, OAuthAdminField, ConnectionAuthKind, RefreshStrategy, TokenEndpointAuthMethod.
  • New runnable example: examples/google (a connection grouping gmail + gsheet).

License

MIT — see LICENSE.