@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 install @types/node@^18+ and list it in "types" (e.g. "types": ["node"]) — TypeScript 6 no longer loads @types/* packages that types does not name |
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/google — defineConnection 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 },
],
}
scopesis the connection-level OAuth authorize scope set (≥1) the engine joins onto the authorize URL — distinct from a CLI's per-toolvendorScopes.- Set
pkcewhen the provider requires it; setextraAuthorizeParamsfor static authorize params (Google'saccess_type: 'offline'to get a refresh token); leaverefreshStrategyat the defaultcron(a background job refreshes the token before it expires) or setjitto refresh just-in-time on the next use. adminFieldsdeclares which OAuth-app credentials an admin must enter (env-stylekey+ UIlabel). The VALUES are stored encrypted in akai-app, never in the manifest or the per-tenant image.- Mark a field
secret: trueso 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.
Hosted MCP server connections
A connection can stand for a vendor's hosted MCP server (Notion, Linear, Stripe, …) instead of a set of CLIs. Set mcpServer:
export default defineConnection({
slug: 'notion-mcp',
name: 'Notion',
vendor: 'Notion',
blurb: 'Search and edit Notion pages through the Notion MCP server.',
longDesc: 'Sign in with Notion. Akai talks to the server directly; no CLI tools are involved.',
authKind: 'oauth2', // or 'none' for an open server
mcpServer: { url: 'https://mcp.notion.com/mcp' }, // must be https
});
- The host (akai-app) turns the connection into an MCP connection. With
authKind: 'oauth2'it discovers the server's OAuth metadata at sign-in and registers itself through RFC 7591 dynamic client registration, so there is no static OAuth client and no admin fields:oauthmust not be set. authKindmust beoauth2ornone(apiKeyis not supported yet).secrets,apiKeyHeaderandjwtRefreshmust not be set, andnativeBuiltinmust not betrue.- It is standalone: no
defineCLIlinks to its slug, and the registry still lists it. On/tools/listit appears inconnectionswithmcpServer: { url }(the field is omitted on every other connection).
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— sandboxedfetchbound to the tool's egress allowlist.secrets— only the secret keys the tool declared insecretKeys. Other workspace secrets are inaccessible.properties— only the property keys the tool declared inpropertyKeys. Non-sensitive tenant config (base URL, region) lives here, not insecrets.logger— structured logger; writes to stderr. stdout is reserved for the tool's result.signal— requestAbortSignal. Forward it toctx.fetchfor 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()); stripsfile://, follows symlinks on every existing parent segment, and throwsAkaiPathErroron escape.readFile— read a local path (resolved viasafePath) or anhttp(s)://URL; pairs withfilePath()from the SDK so handlers transparently support cross-pod file delivery.
What ctx deliberately omits
- No
process.envaccessor — secrets flow throughctx.secretsso the runtime can scope and audit them. - No
fs,child_process, orworker_threadshandles —ctxdoesn'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. buildCtx resolves the reference itself — extracting the hostname from the bound secret or property value — so both the in-process step-fetcher path and the executor derive the same allowlisted host. Resolution throws AkaiSpecError on an unsupported prefix, AkaiSecretError/AkaiPropertyError when the value is unset or not a valid host.
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.commatches itself and*.api.example.com;evil.api.example.com.attacker.tlddoes 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. The one production exception is an operator pin passed by the host runtime — see Operator-pinned private egress.
- Redirects — the SDK never follows a redirect, so a
Location:to another host cannot smuggle a request past the egress check.redirect: 'error'is the default and'follow'is refused (it is downgraded to'error'), because following is the one mode that could leave the allowlisted host after the check has passed.redirect: 'manual'is honoured: it follows nothing, so the handler receives the 3xx itself — which is how a form-post portal reports success — and reaching theLocation:costs a freshctx.fetchthat re-runs the whole preflight. - Identity headers —
user-agent,x-akai-cli,x-akai-tool,x-akai-request-idare stamped on every request.x-akai-tenantis stamped only when the host runtime passes a tenant id tobuildCtx; 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. Under redirect: 'error' — the default, and where 'follow' is downgraded to — a redirect surfaces instead as a fetch rejection after the initial response: same outcome (no follow-through), different error type. Under 'manual' there is no failure to surface, because the 3xx is a successful result: it is returned to the handler as an ordinary response.
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.
Operator-pinned private egress (host runtime only)
The DNS preflight refuses every private address, because a tool's host can come from a connection secret that a tenant user controls. A host runtime that deploys next to one platform-owned internal service can open exactly one exception for it. It passes privateEgress to executeTool (or buildCtx, or createSafeFetch):
import { executeTool } from '@akai-workflow-builder/cli-sdk/runtime';
await executeTool({
/* …registry, body, logger, signal, workdir… */
privateEgress: [
{
cli: 'grafana', // the CLI id; no other CLI may use this entry
host: 'grafana.shared.akai', // exact hostname; no suffix match, never an IP literal
cidrs: ['10.33.0.0/16'], // the host must resolve inside these (each at most /16)
port: 443, // optional, default 443
caPem: '-----BEGIN CERTIFICATE-----…', // optional CA for this connection; default trust store otherwise
},
],
});
It is off by default: with no privateEgress the behaviour is exactly what is described above. The value comes from the host runtime's own configuration and from nowhere else — a tool definition, its options, secrets, properties, the request body and the process environment are never read as a pin.
A pin allows a call to reach a private address only when ALL of these hold; otherwise the unchanged resolved to private address error is thrown:
- the calling CLI id equals
cli; - the URL host equals
hostexactly (case-insensitive;x.hostandevilhostdo not match) and is also on the tool's own egress allowlist; - the scheme is
https:and the port equalsport; - EVERY resolved address is inside
cidrs, and none is loopback, link-local (including the cloud metadata address) or unspecified. IPv4-mapped IPv6 forms (::ffff:127.0.0.1,::ffff:7f00:1) are judged as the IPv4 address they are.
The allowed call goes over node:https, held to what the check saw: the socket connects only to the addresses that passed (a later DNS answer cannot redirect it), the TLS name is the pinned host, caPem — when set — replaces the trust store for that connection, certificate verification stays on, and the Host header is forced to the pinned host whatever the handler set. Redirects follow the usual rule: refused unless redirect: 'manual', and following a Location: is a fresh ctx.fetch with the full check.
An entry is ignored (and logged once through logger.warn) rather than half-applied when its cli is not a CLI id, its host is an IP literal or not a fully qualified name, cidrs is empty or malformed, a CIDR is wider than /16 or overlaps loopback, link-local or unspecified space (127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8, ::1, fe80::/10, the IPv6 metadata address), port is not 1–65535, or caPem is not a PEM certificate.
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:
- Planner hint — exposed in the tool listing so plan UIs render write steps distinctly.
- 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 install @types/node@^18+ and add "types": ["node"] (TypeScript 6 defaults types to [], so installing it alone is not enough).
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.1 — tool() 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 itsoauthshape. Returns a frozen,WeakSet-brandedConnectionDef. Symmetric todefineCLI. - Add the
oauthblock:authorizeUrl,tokenUrl,scopes,pkce,tokenEndpointAuthMethod,extraAuthorizeParams,refreshStrategy, andadminFields(the OAuth-app credentials an admin enters; values stay in akai-app). Validated for valid URLs, non-emptyscopes, env-style admin keys, andadminFieldspresence whenauthKindisoauth2. - New exports:
defineConnection,isAkaiConnection,AkaiConnectionError, and typesConnectionSpec,ConnectionDef,ConnectionOAuthConfig,OAuthAdminField,ConnectionAuthKind,RefreshStrategy,TokenEndpointAuthMethod. - New runnable example:
examples/google(a connection groupinggmail+gsheet).
License
MIT — see LICENSE.