Host adapters and extensions
Connect your own model provider, uploads and storage through host adapters.
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.
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:
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:
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:
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:
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:
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.