Lumen ChartsLumen Charts
AI integration

Host adapters and extensions

Connect your own model provider, uploads and storage through host adapters.

Download Markdown

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' });
FieldAccepts
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 fieldContract
name, version, descriptionNamespaced stable identity (acme.markEarnings), positive version (default 1) and purpose
effectread, mutation or external, checked against session policy
input, outputField maps (or schemas); both boundaries are validated
execute(input, context)Typed handler that obeys cancellation, deadline and target ownership
instructions, examplesModel 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
concurrencyshared or exclusive; defaults to shared for reads, exclusive otherwise
toolNamesnake_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.

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

Run the model on a server →

On this page