@lua-ai-global/chat-contract
v0.2.0
Published
The contract between Lua's server and every chat surface: the `::: form` engine (Lua Form v1) and whether a turn reached the server.
Readme
@lua-ai-global/chat-contract
The contract between Lua's server and every chat surface. It ships no UI and no network code; each app draws with its own design system and sends with its own transport. Formerly @lua-ai-global/chat-blocks.
npm install @lua-ai-global/chat-contractImport from a subpath. There is no root entry, so a client loads only what it uses:
| Subpath | What |
| ------------------------------------- | ------------------------------------------------------------------------------------------------ |
| @lua-ai-global/chat-contract/form | The ::: form engine (Lua Form v1). Pulls in yaml: import it lazily where bundle size matters |
| @lua-ai-global/chat-contract/turns | Whether a turn reached the server, and what the person sees on their message |
| @lua-ai-global/chat-contract/blocks | How every surface reads the ::: blocks agents write. No dependencies |
A form
An agent, or a tool the agent calls, writes a form inline in its reply. The body is YAML (JSON also parses):
::: form
id: counters-check
title: Counter check
fields:
- type: choice
key: counters
label: Counters clean?
options: [Pass, Fail, N/A]
required: true
- type: textarea
key: issue
label: What's wrong?
visible_if: { field: counters, equals: Fail }
- type: photo
key: issue_photo
label: Photo of the issue
max_files: 3
visible_if: { field: counters, equals: Fail }
submit: Submit check
:::When the user submits, the client sends one user message:
::: form-response
{"form":"counters-check","status":"submitted","values":{"counters":"Fail","issue":"Sticky residue"}}
:::Rendering a form
import {
parseFormBlock,
createFormState,
reduceFormState,
visibleNodes,
shownError,
canSubmit,
buildResponse,
formStatus,
formFallbackText,
FORMS_CAPABILITY,
} from '@lua-ai-global/chat-contract/form';
const result = parseFormBlock(body); // the text between `::: form` and `:::`, verbatim
if (!result.ok) return show(result.fallbackText ?? 'This form can’t be shown here.');
let state = createFormState(result.form);
state = reduceFormState(result.form, state, { type: 'change', key: 'counters', value: 'Fail' });
visibleNodes(result.form, state.values); // the nodes to draw, in order
state = reduceFormState(result.form, state, { type: 'submit' });
if (canSubmit(state)) send(buildResponse(result.form, state.values, { tz }));formStatus(form.id, laterMessages)says whether the form isopen,submittedordeclined(with the response),superseded, orunknown(newer history not loaded, so render it read-only).- A client that renders forms sends
FORMS_CAPABILITY(forms-v1) inclientCapabilities. - Channels that can't draw a form send
formFallbackText(form).
Field types
| type | value | notes |
| ----------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
| heading, subheading, paragraph, caption | none | text (paragraph and caption are markdown) |
| image | none | src (https), alt |
| link | none | text, url (https, mailto, tel) |
| divider | none | none |
| text | string | format: plain, email, phone, url, password, passcode. Also min_length, max_length, pattern |
| textarea | string | min_length, max_length |
| number | number | min, max, step |
| date | YYYY-MM-DD | min/max (a date, today, today+N), unavailable |
| date_range | {start, end} | min, max, min_days, max_days. Both ends are inclusive |
| time | HH:mm | min, max, step_minutes |
| datetime | YYYY-MM-DDTHH:mm | min, max |
| choice | string, or string[] when multiple | options, multiple, appearance (radio, checkbox, dropdown, chips), min_selected, max_selected |
| boolean | true/false | appearance (checkbox, switch, yes_no), link |
| rating | integer | min, max |
| file | [{url, name, media_type, size}] | accept, max_files, max_size_mb, source |
Keys every input takes:
key,label,help,placeholderrequired,default,read_only,errorrowvisible_if
visible_if is a structured condition:
- A leaf is
{ field, equals | not_equals | in | not_in | gt | gte | lt | lte | contains: value }or{ field, empty: true|false }. - Leaves combine with
all,anyandnot.
Aliases. Common names map to the types above: email, phone, radio, dropdown, checkbox, photo, document, yes_no and others.
Older pipe-syntax forms still parse.
Forward compatibility
- Versioning.
versionandrequireslet later versions add features. A client that can't honour them showsfallback_text. - Reserved now:
screens,ref,data,context,localeandstyle, plus the node typesgroupandrepeat. v1 clients reject forms that use them, and those forms fall back to text. - Unknown node types:
- Optional ones are dropped.
- A required one makes the form fall back.
- A node can name its own
fallback.
Turns
A client mints a turn id once, when it seals a turn (check it with isTurnId), and sends it on every attempt as the Idempotency-Key header and the body's clientTurnId. The server echoes it on the reply's start chunk (messageMetadata.clientTurnId) and on the user's row in history.
- A resend with the same key never runs the turn twice.
409 TURN_IN_FLIGHTmeans it is still running, and409 TURN_ALREADY_PROCESSEDmeans it finished.deliveryFromResendreads both. - A resend of a finished turn is answered with its stored reply, and the
startchunk saysreplayed: true(TurnStartMetadata).409 TURN_ALREADY_PROCESSEDremains for a reply that can't be rebuilt. GET /chat/stream/:agentId/turns/:clientTurnIdanswersTurnStatusResponsewithout side effects.deliveryFromStatusturns it into what the person sees.- The history page lists
TURN_IDEMPOTENCY_CAPABILITYinserverCapabilitieswhen the server dedupes keyed turns.
| DeliveryState | Meaning |
| --------------- | --------------------------------------------------------- |
| waiting | Still on the device: offline, or queued behind a reply |
| sending | On its way, and no reply chunk yet |
| sent | The server has it: the reply started, or it is in history |
| still-working | The connection dropped while the agent kept going |
| not-sent | It never arrived, and it won't be sent again on its own |
| check | It may have arrived, and the server couldn't be asked yet |
A 200 on the request means only that the server has the request. The turn can still be merged into a batch or refused before it runs, so sent waits for the first reply chunk.
Blocks
Agents write ::: blocks in their replies: actions, list-item, horizontal-list-item, images, links, documents, payment, reaction, flow, navigate, hide, form and form-response. /blocks is the one grammar every chat surface and channel reads them with.
import { parseMessage } from '@lua-ai-global/chat-contract/blocks';
parseMessage('Pick one:\n::: actions\n- Yes\n- No\n:::');
// [{ kind: 'text', text: 'Pick one:' }, { kind: 'actions', items: ['Yes', 'No'], closed: true }]parseMessage(text, { streaming })returns what to draw, in order. It dropshideblocks, shows an unknown block's body as text, drops a block with nothing usable in it, and while streaming returns{ kind: 'pending' }for a block whose closer hasn't arrived (draw a placeholder, except forhide,reactionandnavigate).splitBlocksreturns the raw segments, andstripBlocks(text, names)removes blocks and keeps the rest of the text as written.- The body readers (
parseActions,parseCard,parseImages, …) andflowTextare exported for surfaces that render a single kind.
The grammar:
- A marker may use any spacing or case, and may be indented:
::: actions,:::actionsand::: Actionsare the same. A line of only colons (:::,::,::::) closes a block. ::: reaction emoji=👍 :::on one line is a whole block. Text after an opener is the block's first line.- An opener or closer glued to text at the end of a line counts (
Sure!::: actions,- Yes:::). Any other:::in the middle of a line is text. - A new opener closes a block that is still open. A lone closer is dropped. Anything inside a code fence is code.
- A block still open when a finished message ends renders. An unclosed item block keeps only its own lines, and the prose after them is text.
- A
hideblock ends only at a column-0 closer, so hidden context can carry indented:::lines. - A literal
\noutside code is a line break. Form bodies are kept verbatim. - Card headings work with or without a space (
#Title,## Sub). Links allowhttp(s),mailto:andtel:. Images, payment and documents allowhttp(s)only. Navigate takes an app route or anhttp(s)URL.
React Native and Expo
The package is plain ES2020 and runs under Hermes. Every subpath has a CommonJS build for jest. Under jest-expo, add yaml to transformIgnorePatterns, because jest resolves yaml's ESM browser build.
Fixtures
@lua-ai-global/chat-contract/fixtures/forms/corpus.json holds sample bodies covering every type, with the expected parse outcome. Renderer tests can use them as a smoke suite.
@lua-ai-global/chat-contract/fixtures/blocks/corpus.json holds one message per grammar rule, with the expected splitBlocks segments. Run it through your own adapter to check a surface reads blocks the same way as every other.
