<!-- Public documentation export; not library implementation source. -->
# Host adapters and extensions

## Custom commands

Declare a command with `defineCommand`. The `field` declarations produce both the JSON Schema your model sees and the parser the SDK runs, so the two cannot drift, and the handler's types are inferred from them.

```ts
import { defineCommand, field } from '@lumencharts/chart-automation/sdk';

const markEarnings = defineCommand({
  name: 'acme.markEarnings',
  effect: 'mutation',
  description: 'Mark the next earnings date with a vertical line.',
  instructions: 'Use only when the user asks about earnings.',
  examples: [{ description: 'Apple', input: { symbol: 'AAPL' } }],
  input: { symbol: field.string(16).describe('Ticker symbol, for example AAPL.') },
  output: { drawingId: field.string(64).describe('ID of the created line.') },
  async execute({ symbol }, context) {
    const time = await fetchNextEarnings(symbol, context.signal);
    const receipt = await context.execute('drawings.apply', {
      operations: [{ action: 'create', type: 'vertical_line', points: [{ time }] }],
    });
    return { drawingId: receipt.ids[0]! };
  },
});

const unregister = sdk.extensions.register(markEarnings);
await unregister.execute({ symbol: 'AAPL' });
```

| Field | Accepts |
| --- | --- |
| `field.string(max, { pattern })` / `field.text(max, { minLength, pattern })` | Non-blank / any string up to `max` Unicode code points. Supply an anchored (`^…$`) regular expression without flags. The parser evaluates its source in Unicode mode. |
| `field.integer({ minimum, maximum })` / `field.number(...)` | Safe integers / finite numbers; above `maximum` rejects with `limit_exceeded` |
| `field.boolean()`, `field.literal(value)`, `field.oneOf([...])` | Booleans, one exact value, one of a set |
| `field.array(item, { maxItems, minItems, uniqueItems })` | Bounded arrays with a required `maxItems`. `uniqueItems` rejects repeated JSON values regardless of object property order. |
| `field.object({...}, { minProperties })` | Nested closed objects; `minProperties` requires that many fields to be present |
| `field.union(discriminator, variants, { minProperties })` | Closed objects told apart by a string discriminator, advertised as `oneOf` with a `const` per variant; `minProperties` per variant counts the discriminator |
| `field.record(value, { maxKeys, maxKeyLength })` | Objects with your keys and one value shape; `maxKeys` is required, keys are 1 to `maxKeyLength` (default 64) characters |
| `field.scalar()` | A string, finite number or boolean |
| `field.unknown()` | Any JSON value, advertised without constraints; use only where your handler validates the value itself |
| `field.optional(inner)`, `field.nullable(inner)` | May be omitted / may be `null` |

Every field takes `.describe(text)` (1 to 1,000 characters), which becomes the field's `description` in the JSON Schema. Describe every field: the model sees nothing else about it. Descriptions pass through nested objects, arrays, unions and records, and `.describe()` keeps the field's inferred type.

Unions are keyed by discriminator value; each variant lists its other fields, and the handler receives a discriminated TypeScript union:

```ts
const annotate = defineCommand({
  name: 'acme.annotate',
  effect: 'mutation',
  description: 'Add price annotations.',
  input: {
    marks: field.array(field.union('kind', {
      level: { price: field.number().describe('Price of the level.') },
      zone: {
        from: field.number().describe('Lower price.'),
        to: field.number().describe('Upper price.'),
        label: field.optional(field.string(32)).describe('Short label.'),
      },
    }).describe('One annotation.'), { maxItems: 10 }).describe('Annotations to add.'),
  },
  output: { added: field.integer().describe('Annotations added.') },
  execute({ marks }) {
    for (const mark of marks) if (mark.kind === 'zone') console.log(mark.from, mark.to);
    return { added: marks.length };
  },
});
```

Unknown fields always reject. A rejected input carries `details` with the field path and a short fixed reason, for example `{ path: 'marks[2].to', reason: 'expected number' }`, and model tools return them as `{ error: 'invalid_input', details }` so the model can correct the call. Details never contain input values: paths name declared fields and array indexes only. Lower-level `AutomationCommand` definitions with a hand-written `{ jsonSchema, parse }` remain supported for shapes the builder cannot express; throw `new ChartCommandError(code, code, { path, reason })` to report details from your own parser.

| Command field | Contract |
| --- | --- |
| `name`, `version`, `description` | Namespaced stable identity (`acme.markEarnings`), positive version (default 1) and purpose |
| `effect` | `read`, `mutation` or `external`, checked against session policy |
| `input`, `output` | Field maps (or schemas); both boundaries are validated |
| `execute(input, context)` | Typed handler that obeys cancellation, deadline and target ownership |
| `instructions`, `examples` | Model guidance (up to 4,000 characters, joined by `sdk.instructions()`) and up to 8 examples, each checked against `input` at registration; `createAutomationTools` appends examples to the tool description as compact JSON lines, up to 2,000 characters |
| `concurrency` | `shared` or `exclusive`; defaults to `shared` for reads, `exclusive` otherwise |
| `toolName` | snake_case model name; defaults to the derived name |
| `targetChange` | `'synchronous'` only for a synchronous mutation that intentionally changes the chart target |
| `dispose()` | Optional cleanup, called once on unregister or session destruction |

The namespaces `data`, `capture`, `sdk`, `drawings`, `studies`, `settings`, `workspace` and `capabilities` are reserved. Native commands use a protected registrar, so an extension cannot replace a typed built-in operation.

### Composing commands

`context.execute(name, input)` runs another command, or an SDK port (`data.series`, `data.read`, `data.requestHistory`, `capture.image`), as part of the same request. Children share the signal, deadline and target fence, get a derived request ID and do not take another concurrency slot. A child's effect must be `read` or match the parent's, so a `read` command can never mutate or reach an external service. Target-changing commands cannot run as children, and nesting is limited to four levels. Calls to ports and built-in commands are typed: the result has the command's output type, and the input must match its input type or be typed `unknown` (the child validates it either way).

## Host ports

Use `createChartAutomation(host, policy)` when your application supplies its own data, capture and lifecycle integration. `openAutomation` and `createNativeChartAutomation` accept `additionalData`, `requestHistory` and `subscribeAdditionalData` for the standard chart.

| Port | Host responsibility |
| --- | --- |
| `currentTarget()` | Stable chart ID and generation that changes on selection or replacement, including A → B → A |
| `data.series()` | Stable unique series IDs, numeric field declarations, units and readiness |
| `data.read(input, context)` | Bounded local rows, revision and explicit loaded coverage |
| `data.requestHistory(input, context)` | Optional bounded provider ingestion, cancellation and coverage without navigating the chart |
| `capture.image(context)` | Local PNG and metadata belonging to the requested target |
| `subscribe(listener)` | Publish committed lifecycle/data changes; return a cleanup function |

The SDK validates declared fields and response shapes. Discovery permits up to 256 series with 128 fields per series; reads permit at most 10,000 rows and remain subject to the JSON byte budget. A descriptor uses this shape:

```ts
const series = {
  id: 'analytics.rsi',
  kind: 'study',
  timeUnit: 'milliseconds',
  readiness: 'ready',
  fields: [{ name: 'value', type: 'number', nullable: true }],
} as const;
// A warm-up row can be { time: 1735689600000, value: null }.
// A calculated row can be { time: 1735776000000, value: 52.4 }.
```

`time` is implicit and cannot be declared as a field. Undeclared fields, missing required values, non-finite numbers and unordered timestamps reject. History responses follow the same validation. Keep the provider's own work bounded before constructing results; a response-size check cannot make an unbounded database query safe.

## Durable saves

Native drawing and study changes apply locally with Undo. To save them before they appear, pass `persist`:

```ts
const sdk = await chart.openAutomation({
  async persist(change, context) {
    // change.family is 'drawings' or 'studies'; change.prepared.document is the candidate.
    await saveDocument(change.family, change.prepared.document, { operationId: context.requestId, signal: context.signal });
  },
});
```

`persist` runs after native preparation and before commit, while a mutation lease blocks manual edits. Reject to abort: the candidate is discarded and nothing is applied. If the request is cancelled after `persist` resolves, the change is saved but not applied; reconcile it as you would a lost response. Chart settings are never persisted.

## Side effects and lifecycle

The SDK rejects stale asynchronous results, but it cannot undo an external side effect or forcibly stop JavaScript. Check the AbortSignal and target before committing asynchronous work.

Completed-request idempotency belongs to your custom handler; the SDK only rejects duplicate in-flight request IDs. Native commands retain their native replay protection. This is an API for trusted extensions, not an untrusted-code sandbox.

Close sessions and release host subscriptions when the owning view is removed. Session cleanup attempts remaining command disposers even when one fails. Keep uploads, AI prompts and budgets, conversation storage and entitlement checks in host adapters so the same chart product works across applications.

## Testing commands

`createTestAutomation` gives you an in-memory chart for unit tests:

```ts
import { createTestAutomation } from '@lumencharts/chart-automation/sdk/testing';

const { sdk, select, setRows, remove } = createTestAutomation({
  rows: { main: [{ time: 1, open: 1, high: 2, low: 1, close: 2 }] },
});
sdk.extensions.register(myCommand);
expect(await sdk.execute('acme.lastClose', {})).toEqual({ close: 2 });

const earlier = sdk.request();
select();                                        // a symbol or interval change
await expect(sdk.execute('acme.lastClose', {}, earlier)).rejects.toMatchObject({ code: 'stale_binding' });
```

`setRows` replaces loaded rows and publishes `data`; `remove` simulates chart removal. Capture returns a 1×1 PNG unless `capture: false`.

### Native commands without a widget

Pass `owners` to install the native `drawings.*`, `studies.*`, `settings.*` and `workspace.*` commands, plus an optional `persist` hook, exactly as `installNativeAutomationCommands` does on a chart. `createFakeDrawingOwner()` is an in-memory drawing owner with native receipts, revisions, mutation leases and Undo, so a command that calls `context.execute('drawings.apply', …)` can be tested end to end:

```ts
import { createFakeDrawingOwner, createTestAutomation } from '@lumencharts/chart-automation/sdk/testing';

const drawings = createFakeDrawingOwner({ binding: { symbol: { ticker: 'AAPL' } } });
const saved: string[] = [];
const { sdk, select } = createTestAutomation({
  policy: { effects: ['read', 'mutation'] },
  owners: {
    drawings,
    async persist(change, context) {
      saved.push(context.requestId);            // runs before commit, under a lease
    },
  },
});
const command = sdk.extensions.register(markEarnings);

const { drawingId } = await command.execute({ symbol: 'AAPL' });
expect((await sdk.drawings.list({ offset: 0, limit: 10 })).drawings[0]?.id).toBe(drawingId);
expect(drawings.undo(saved[0]!)).toBe(true);   // native Undo by operation ID

const earlier = sdk.request();
select();
await expect(command.execute({ symbol: 'AAPL' }, earlier)).rejects.toMatchObject({ code: 'stale_binding' });
```

The fake supports `create`, `update` and `delete` operations on plain drawing summaries; group operations reject with `unsupported`. `leased()` reports whether a mutation lease is held, and `manualEdit(operationId, change)` simulates a hand-made edit that joins the same Undo history. Pass real owners from a chart instead to test against native behavior. `binding` defaults to the drawing owner's binding.

[Run the model on a server →](https://docs.lumencharts.com/charting-library/automation/remote)

---
Documentation page: https://docs.lumencharts.com/charting-library/automation/extensions
