npm.io
0.1.15 • Published 2d agoCLI

@akai-workflow-builder/cli-sdk

Licence
MIT
Version
0.1.15
Deps
0
Size
191 kB
Vulns
0
Weekly
2.9K

@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.

Keywords