Skip to content

feat: TypeScript client (proposition) #148

Description

@0xpolarzero

A fully-typed TypeScript client for calling incur commands in memory or over RPC.

This implementation is stacked into 5 PRs, the 3 lower ones making small fixes not directly related but required for the client, and the 2 at the top implementing the client.

Most of the code is written with AI, but anything marked ready for review I’ve carefully reviewed and polished the code myself. The API however I've spent a lot of time figuring out and polishing myself. You'll recognize viem patterns which I reproduced when it made sense to me; basically transports (http or in-process) -> typed actions -> clients.

Overview

The purpose is to provide a TypeScript client that is a fully-typed wrapper around a transport to interact with a similar API as the CLI. There are two transports:

  • HttpTransport which posts to newly introduced RPC routes;
  • MemoryTransport which runs shared logic in process.
    The RPC and in-memory APIs reuse shared logic for executing commands (run) and returning resources (discover), e.g. llms, llmsFull, schema.

The memory transport has full parity with the CLI, and the RPC transport as well except filesystem commands (listing/syncing skills, adding MCP).

The upstream PRs are opened against wevm/incur, but they come from a fork, so GitHub cannot show the stacked bases there. For easier review, the same PRs also exist in 0xpolarzero/incur as a mirror with the correct bases so you can review from stacked diffs.

PRs:

  1. fix: normalize object validation errors #144
  2. fix(cli): preserve stream terminal records #149 (mirror)
  3. fix: reuse CLI skill projection #154 (mirror)
  4. [TypeScript client] feat: RPC & in-memory transports #153 (mirror)
  5. [TypeScript client] feat: RPC & in-memory clients #155 (mirror)

Below is a documented example of the entire API, which will serve as client skill as well.

API

import { Cli } from 'incur'
import {
  Client,
  HttpClient,
  MemoryClient,
  HttpTransport,
  MemoryTransport,
} from 'incur/client'

import type { Commands } from './generated/incur-client.js'

/**
 * Client
 */
const client = HttpClient.create<Commands>({
  baseUrl: 'https://ops.acme.test',
  // Optional, defaults to globalThis.fetch.
  fetch,

  // Defaults for every client.run(). Per-call options override these.
  // output* options affect result.output.text but not the (full) result.data.
  outputFormat: 'toon', // --format toon
})

// which is exactly the same as:
const _clientViaTransport = Client.create<Commands>({
  transport: HttpTransport.create({
    baseUrl: 'https://ops.acme.test',
  }),
  outputFormat: 'toon',
})

// Or create an in-process memory client.
const cli = Cli.create({ name: 'acme' }) // ...
// Memory clients run in-process, so explicit env injection is allowed here.
const memoryClient = MemoryClient.create(cli, {
  env: { ACME_TOKEN: 'dev_secret_123' },
})

// identical to:
const _memoryClientViaTransport = Client.create<Commands>({
  transport: MemoryTransport.create(cli, {
    env: { ACME_TOKEN: 'dev_secret_123' },
  }),
})

/**
 * Running
 */
// `acme project report proj_web_2026 --include-closed=false --filter-output summary items[0:3] nextCursor --format md --token-count --token-limit 24 --full-output`
const report = await client.run('project report', {
  args: { projectId: 'proj_web_2026' },
  options: { includeClosed: false },

  // Applies first to structured data (report.data), so report.data is typed as unknown.
  selection: ['summary', 'items[0:3]', 'nextCursor'],

  // output* options apply only to report.output.
  // They format/count/page report.output.text; they never change report.data.
  outputFormat: 'md',
  outputTokenCount: true,
  outputTokenLimit: 24,
})

// which could be requested from the transport directly:
const httpTransport = HttpTransport.create({
  baseUrl: 'https://ops.acme.test',
  fetch,
})()

const _reportViaTransport = await httpTransport.request({
  command: 'project report',
  args: { projectId: 'proj_web_2026' },
  options: { includeClosed: false },
  selection: ['summary', 'items[0:3]', 'nextCursor'],
  outputFormat: 'md',
  outputTokenCount: true,
  outputTokenLimit: 24,
})

console.log(report)
/// Run.Result<unknown, Commands>
// {
//   ok: true,
//   data: {
//     summary: 'Website refresh is on track',
//     items: [
//       { id: 'task_1', title: 'Finalize copy', status: 'done' },
//       { id: 'task_2', title: 'QA checkout flow', status: 'blocked' },
//       { id: 'task_3', title: 'Publish launch checklist', status: 'open' }
//     ],
//     nextCursor: 'task_4'
//   },
//   output: {
//     text: '## Website refresh is on track\n\n- done: Finalize copy\n- blocked: QA checkout flow',
//     format: 'md',
//     tokenCount: 37,
//     tokenLimit: 24,
//     tokenOffset: 0,
//     next: [Function]
//   },
//   meta: {
//     command: 'project report',
//     duration: '18ms',
//     cta: { ... }
//   }
// }

console.log(typeof report.data) // unknown

if (report.output?.next) {
  const nextPage = await report.output.next()
  console.log(nextPage?.output?.text)
  // '- open: Publish launch checklist'
}

// `acme project status proj_web_2026 --full-output`
const status = await client.run('project status', {
  args: { projectId: 'proj_web_2026' },
})

console.log(status)
/// Run.Result<ProjectStatus, Commands>
// ...

/**
 * CTA
 */
const cta = report.meta.cta?.commands[0]
console.log(cta)
/// Run.Cta<Commands>
// {
//   command: 'project unblock',
//   cliCommand: 'acme project unblock task_2',
//   description: 'Unblock the blocked checkout QA task.',
//   args: { taskId: 'task_2' },
//   options: {},
//   runnable: true,
//   run: [Function],
//   raw: {
//     command: 'project unblock',
//     args: { taskId: 'task_2' },
//     options: {},
//     description: 'Unblock the blocked checkout QA task.'
//   }
// }

if (cta?.runnable) {
  console.log(cta)
  /// Run.Cta<Commands>
  // ...
  const unblock = await cta.run({
    // Equivalent to:
    // client.run('project unblock', {
    //   args: { taskId: 'task_2' },
    //   options: {},
    //   outputFormat: 'toon',
    // })
    //
    // CTA run() does not inherit output controls from the original report run.
    outputFormat: 'toon',
  })

  console.log(unblock)
  /// Run.Result<ProjectUnblock, Commands>
  // ...
}

/**
 * Errors
 */
try {
  // acme project deploy proj_web_2026 production --full-output
  await client.run('project deploy', {
    args: { projectId: 'proj_web_2026', environment: 'production' },
  })
} catch (error) {
  if (error instanceof Client.ClientError) {
    console.log(error)
    /// Client.ClientError
    // ClientError: Login required before deploying.
    // {
    //   message: 'Login required before deploying.',
    //   code: 'NOT_AUTHENTICATED',
    //   status: 401,
    //   retryable: false,
    //   fieldErrors: undefined,
    //   meta: {
    //     command: 'project deploy',
    //     duration: '4ms',
    //     cta: {
    //       description: 'Authenticate before deploying.',
    //       commands: [
    //         {
    //           command: 'auth login',
    //           cliCommand: 'acme auth login',
    //           description: 'Log in to Acme.',
    //           args: {},
    //           options: {},
    //           runnable: true,
    //           run: [Function],
    //           raw: { command: 'auth login', description: 'Log in to Acme.' }
    //         }
    //       ]
    //     }
    //   },
    //   error: {
    //     code: 'NOT_AUTHENTICATED',
    //     message: 'Login required before deploying.',
    //     retryable: false
    //   },
    //   data: {
    //     ok: false,
    //     error: {
    //       code: 'NOT_AUTHENTICATED',
    //       message: 'Login required before deploying.',
    //       retryable: false
    //     },
    //     meta: {
    //       command: 'project deploy',
    //       duration: '4ms',
    //       cta: { ... }
    //     }
    //   }
    // }

    // Needs to be typed explicitly
    const clientError = error as Client.ClientError<Commands>
    console.log(clientError)
    /// Client.ClientError<Commands>
    // ...
  }
}

/**
 * Streaming
 */
// `acme logs tail checkout-api --format toon`
const stream = await client.run('logs tail', {
  args: { service: 'checkout-api' },
})

for await (const chunk of stream) {
  console.log(chunk)
  /// Logline
  // { timestamp: '2026-05-24T10:15:00Z', level: 'info', message: 'request completed' }
}

console.log(await stream.final)
/// Run.StreamFinal<unknown, Commands>
// {
//   ok: true,
//   data: { lines: 124 },
//   meta: { command: 'logs tail', duration: '30s' }
// }

// A stream can only be consumed once: either for await (...) or records().
const rawStream = await client.run('logs tail', {
  args: { service: 'checkout-api' },
})

// records() yields every stream record, including error records.
// It does not throw when an error record arrives.
for await (const record of rawStream.records()) {
  console.log(record)
  /// Run.StreamRecord<LogLine, unknown, Commands>
  // ...
  if (record.type === 'chunk') {
    console.log(record.data)
    // ...
  }

  if (record.type === 'done') {
    console.log(record.data)
    /// string | undefined
    // { lines: 124 }
    console.log(record.meta)
    /// Run.Meta<Commands>
    // { command: 'logs tail', duration: '30s' }
  }

  if (record.type === 'error') {
    console.log(record.error)
    /// Rpc.Error
    // { code: 'LOG_STREAM_DISCONNECTED', message: 'Log stream disconnected.' }
  }
}

/**
 * DiscoveryActions
 *
 * These actions are read-only and available on both HttpClient and MemoryClient:
 * - client.llms(options?): Promise<LlmsManifest | string>
 *   Compact LLM manifest; structured by default, string with format.
 *
 * - client.llmsFull(options?): Promise<LlmsFullManifest | string>
 *   Full LLM manifest; structured by default, string with format.
 *
 * - client.schema(command?): Promise<CommandSchema>
 *   JSON Schema for root or command args/env/options/output.
 *
 * - client.help(command?): Promise<string>
 *   CLI help text for root or command.
 *
 * - client.openapi(): Promise<OpenApiDocument>
 *   Parsed OpenAPI JSON document.
 *
 * - client.skills.index(): Promise<SkillsIndex>
 *   Structured generated skills index.
 *
 * - client.skills.get(name): Promise<string>
 *   Generated SKILL.md markdown.
 *
 * - client.mcp.tools(): Promise<McpToolsResponse<Commands>>
 *   Structured MCP tool descriptors.
 *
 * LocalActions
 *
 * These actions are available only on MemoryClient. They are not exposed by
 * HttpClient, HTTP routes, RPC, or MCP tools:
 * - memoryClient.skills.add(options?): Promise<SyncedSkills>
 *   Sync generated skill files to local agent skill directories.
 *
 * - memoryClient.skills.list(options?): Promise<SkillsList>
 *   List generated skills with local install status.
 *
 * - memoryClient.mcp.add(options?): Promise<McpRegistration>
 *   Register this CLI as a local MCP server with supported agents.
 */
const llmsFull = await client.llmsFull({ command: 'project' })
console.log(llmsFull.commands[0])
/// LlmsFullManifest<Commands, 'project'>['commands'][number]
// {
//   name: 'project report',
//   description: 'Summarize project progress.',
//   schema: {
//     args: { type: 'object', required: ['projectId'], properties: { projectId: { type: 'string' } } },
//     options: { type: 'object', properties: { includeClosed: { type: 'boolean' } } },
//     output: { type: 'object', properties: { summary: { type: 'string' } } }
//   }
// }

// Discovery methods are not command runs, so they use `format`.
// `format` changes the discovery response itself from typed data to text.
const llmsMd = await client.llms({ command: 'project', format: 'md' })
console.log(llmsMd)
/// string
// '# Project commands\n\n- `project report` - Summarize project progress.\n- `project status` - Show project status.'

const schema = await client.schema('project report')
console.log(schema.args)
// CommandSchema<Commands, 'project report'>['args']
// { type: 'object', required: ['projectId'], properties: { projectId: { type: 'string' } } }

const help = await client.help('project report')
console.log(help)
// string
// 'Usage: acme project report <projectId> [--include-closed]\n\nSummarize project progress.'

const openapi = await client.openapi()
console.log(openapi.info)
// OpenApiDocument['info']
// { title: 'Acme CLI API', version: '1.0.0' }

const skills = await client.skills.index()
console.log(skills.skills[0])
// SkillsIndex['skills'][number]
// { name: 'deploy', description: 'Deploy safely with preflight checks.', files: ['SKILL.md'] }

const deploySkill = await client.skills.get('deploy')
console.log(deploySkill)
// string
// '# Deploy\n\nRun preflight checks, inspect the deployment plan, then deploy.'

const localSkills = await memoryClient.skills.list()
console.log(localSkills.skills[0])
/// SkillsList['skills'][number]
// ...

const syncedSkills = await memoryClient.skills.add({
  depth: 1,
  global: true,
})
console.log(syncedSkills.skills[0])
/// SyncedSkills['skills'][number]
// { name: 'deploy', description: 'Deploy safely with preflight checks.' }

// You can't use local actions on a http client.
client.skills.add()
// Type error: LocalActions exist only on MemoryClient.

const mcpTools = await client.mcp.tools()
console.log(mcpTools.tools[0])
// McpToolsResponse<Commands>['tools'][number]
// {
//   name: 'project_report',
//   description: 'Summarize project progress.',
//   inputSchema: { type: 'object', properties: { projectId: { type: 'string' } } },
//   outputSchema: { type: 'object', properties: { summary: { type: 'string' } } }
// }

const mcpRegistration = await memoryClient.mcp.add({
  agents: ['codex'],
})
console.log(mcpRegistration)
/// McpRegistration
// {command: 'pnpm acme --mcp', agents: ['Codex']}

Activity

  1. changed the title [-]Track TypeScript client parity for agent workflows[/-] [+]feat: TypeScript client (tracker)[/+] on May 22, 2026
  2. changed the title [-]feat: TypeScript client (tracker)[/-] [+]feat: TypeScript client (proposition)[/+] on May 26, 2026
  3. 0xpolarzero commented on May 26, 2026

    @0xpolarzero
    ContributorAuthor

    Hi @jxom @tmm.

    @jxom told me I could PR this TypeScript client. This will be very useful to me and I know of at least one person who needs it.

    This issue showcases the full proposed API; if you want and if you can find the time, I'd love to get some feedback, to know if I got anything wrong or clunky. I'll start working on the code but that would help steer me in the right direction.

    This will add a lot of code so I thought it's best to implement as stacked PRs; my idea since I can't push a branch directly here is to open mirror PRs on my fork that will have the correct base branches, so effectively show only the stacked diff. Let me know if there is anything else or anything different I can do to make reviewing easier tho.

    Thanks!

  4. 0xpolarzero commented on May 28, 2026

    @0xpolarzero
    ContributorAuthor

    @tmm @jxom PRs are ready for review whenever you get the time!

    First 3 PRs on the stack are fixes/improvements necessary for the Typescript client but that I could extract to ease review. The feature is implemented in the top two PR responsible for (i) the RPC routes and transports and (ii) typed actions and clients.

    I only documented README and skill (for which there is a new dedicated skill only for the Typescript client) in the top (second) PR because the first one is lower-level and would just have been intermediate noise.

    Tell me if there is anything I can do to make review easier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions