@byokit/openclaw 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 (2026-10-01)
6
+
7
+ - Dependency update: pins @byokit/relay 0.5.1.
8
+
9
+ - FIX: Wait for approval callbacks in the bridge parking test instead of assuming the gate finishes within 50 ms.
10
+ - FIX: Successful runs preserve complete generated text in the final callback and result instead of replacing it with a capped terminal snapshot; silent and empty replies remain empty.
11
+
12
+ - Add app-supplied output schemas to host and device runs, with inferred result data, local final-answer validation and typed output failures. Subscription routes and explicit API key (billed per use) opt-in are unchanged.
13
+
5
14
  ## 0.4.0 (2026-10-01)
6
15
 
7
16
  - Dependency update: pins @byokit/ui-core 0.6.0.
package/README.md CHANGED
@@ -94,6 +94,46 @@ ends ok carries `usage` (the engine's token total for the run, and `costUsd` whe
94
94
  `openclawDevice(link).run(message, o)` takes the same options over the link, and `state()` adds the kit and engine
95
95
  versions and the providers the device's member is signed in to.
96
96
 
97
+ For a validated JSON answer, pass a literal `schema`. The result's `data` is inferred from that schema:
98
+
99
+ ```ts
100
+ import type { OpenClawKit } from '@byokit/openclaw';
101
+
102
+ async function summarize(kit: OpenClawKit) {
103
+ const end = await kit.run({
104
+ member: 'umer', sessionKey: 'agent:umer:report', message: 'Summarize the work.',
105
+ schema: {
106
+ type: 'object',
107
+ properties: { summary: { type: 'string' }, ready: { type: 'boolean' } },
108
+ required: ['summary', 'ready'],
109
+ additionalProperties: false,
110
+ },
111
+ });
112
+ if (end.ok && end.data) {
113
+ const summary: string = end.data.summary;
114
+ const ready: boolean = end.data.ready;
115
+ // Show summary and ready in the app.
116
+ }
117
+ return end;
118
+ }
119
+ ```
120
+
121
+ `openclawDevice(link).run(message, { schema })` carries the same schema and typed result over the link.
122
+ The pinned engine has no general schema parameter: the kit adds a JSON-only instruction to the run's
123
+ system prompt, then parses and validates the final answer locally. Partial text events are unvalidated.
124
+ Malformed JSON or a schema mismatch returns `{ ok: false, kind: 'output', message }`; the message never
125
+ includes the answer. The kit sends one agent request, with no kit retries or sign-in changes. Subscription
126
+ routes stay subscriptions; an API key (billed per use) still requires the app's explicit opt-in.
127
+
128
+ The supported JSON Schema subset covers types (including nullable arrays of types), objects with
129
+ `properties`, `required` and `additionalProperties`, arrays with one `items` schema, `enum`, `const`,
130
+ `anyOf`, `oneOf`, `allOf`, `not`, numeric bounds, string/array/object length bounds and `uniqueItems`.
131
+ Annotations `title`, `description`, `default`, `examples` and the draft-07 `$schema` are accepted;
132
+ defaults are never inserted and values are never coerced. Unsupported keywords (including `$ref`,
133
+ `format` and `pattern`), malformed schemas, schemas over 64 KiB or over 32 levels deep throw before
134
+ any Gateway request. Use `as const` for schemas defined separately; dynamic schemas return unknown
135
+ `data`. App-specific business checks belong in the app after validation.
136
+
97
137
  The same kit runs offline against the fake Gateway from `@byokit/openclaw/testing` (no engine, no network, no
98
138
  account), and lists the sign-in routes it offers:
99
139
 
package/dist/device.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { DeviceLink } from '@byokit/link';
2
2
  import { type AccountView } from './words.ts';
3
- import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, Route, RunEnd, RunEvent, SignInView } from './types.ts';
3
+ import type { Approval, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, KitState, Route, RunEnd, OutputSchema, SchemaOutput, RunEvent, SignInView } from './types.ts';
4
4
  export type { GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult } from './types.ts';
5
- export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView } from './types.ts';
5
+ export type { Approval, Decision, KitState, PlanWindow, Route, RunEnd, RunEvent, RunUsage, SignInView, OutputSchema, SchemaOutput } from './types.ts';
6
6
  /** One `oc.events` frame: a member's Gateway event, typed by name, or an approval add/resolve (7.2). */
7
7
  export type OpenClawLinkEvent = {
8
8
  [E in GatewayEventName]: {
@@ -19,8 +19,13 @@ export type SessionRow = {
19
19
  sessionKey: string;
20
20
  [k: string]: unknown;
21
21
  };
22
+ type EndFrame<T = unknown> = {
23
+ type: 'end';
24
+ end: RunEnd<T>;
25
+ };
22
26
  /** `oc.run`'s options beside the message: the member's own session, account, and the kit's run options. */
23
- export type DeviceRunOptions = {
27
+ export type DeviceRunOptions<S extends OutputSchema | undefined = OutputSchema | undefined> = {
28
+ schema?: S;
24
29
  sessionKey?: string;
25
30
  model?: string;
26
31
  auth?: 'apiKey';
@@ -61,10 +66,7 @@ export declare function openclawDevice(link: DeviceLink): {
61
66
  };
62
67
  signOut(p: string): Promise<void>;
63
68
  sessions(): Promise<SessionRow[]>;
64
- run(message: string, o?: DeviceRunOptions): AsyncIterable<RunEvent | {
65
- type: 'end';
66
- end: RunEnd;
67
- }>;
69
+ run<const S extends OutputSchema | undefined = undefined>(message: string, o?: DeviceRunOptions<S>): AsyncIterable<RunEvent | EndFrame<SchemaOutput<S>>>;
68
70
  steer(k: string, t: string, o?: {
69
71
  auth?: 'apiKey';
70
72
  }): Promise<void>;
package/dist/device.js CHANGED
@@ -141,7 +141,7 @@ export function openclawDevice(link) {
141
141
  ...(o?.model !== undefined ? { model: o.model } : {}), ...(o?.system !== undefined ? { system: o.system } : {}),
142
142
  ...(o?.auth !== undefined ? { auth: o.auth } : {}),
143
143
  ...(o?.images !== undefined ? { images: o.images } : {}), ...(o?.thinking !== undefined ? { thinking: o.thinking } : {}),
144
- ...(o?.tools !== undefined ? { tools: o.tools } : {}) }));
144
+ ...(o?.tools !== undefined ? { tools: o.tools } : {}), ...(o?.schema !== undefined ? { schema: o.schema } : {}) }));
145
145
  let finished = false;
146
146
  return {
147
147
  [Symbol.asyncIterator]() {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ENGINE_VERSION, OPERATOR_SCOPES, PROTOCOL_VERSION } from './constants.ts';
2
- export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, RunUsage, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
2
+ export type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, GateResult, Hello, KitState, Member, PlanWindow, Route, RunEnd, RunEvent, RunRef, RunSpec, OutputSchema, SchemaOutput, RunUsage, SignInOptions, SignInView, ToolHost, ToolSpec, } from './types.ts';
3
3
  export { OpenClawKit, type KitOptions, type RetainedLogin } from './kit.ts';
4
4
  export type { AddKeyResult } from './keys.ts';
5
5
  export { stateWords, toAccountView, words, type AccountView, type WordKey } from './words.ts';
package/dist/kit.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { SealingAdapter } from '@byokit/secrets';
2
2
  import { type AddKeyResult } from './keys.ts';
3
- import type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, Member, Route, RunEnd, RunEvent, RunSpec, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
3
+ import type { Approval, CallOptions, Decision, GatewayEventName, GatewayEventPayload, GatewayMethod, GatewayParams, GatewayResult, GatewayTransport, Hello, KitState, Member, Route, RunEnd, RunEvent, RunSpec, OutputSchema, SchemaOutput, SignInOptions, SignInView, ToolHost, ToolSpec } from './types.ts';
4
4
  export type KitOptions = {
5
5
  stateDir: string;
6
6
  authSeal?: SealingAdapter;
@@ -93,7 +93,7 @@ export declare class OpenClawKit {
93
93
  private runs;
94
94
  /** The app tool names (`KitOptions.tools`): the only names `RunSpec.tools` may carry. */
95
95
  toolNames(): string[];
96
- run(spec: RunSpec, on?: (e: RunEvent) => void): Promise<RunEnd>;
96
+ run<const S extends OutputSchema | undefined = undefined>(spec: RunSpec<S>, on?: (e: RunEvent) => void): Promise<RunEnd<SchemaOutput<S>>>;
97
97
  private runKey;
98
98
  steer(sessionKey: string, text: string, o?: {
99
99
  auth?: 'apiKey';
package/dist/kit.js CHANGED
@@ -11,6 +11,7 @@ import { createMembers } from "./members.js";
11
11
  import { keyAgentId, KEY_PREFIX, MEMBER_ID } from "./members.js";
12
12
  import { confirmRetainedLogin as confirmLogin, migrateRetainedLogin as migrateLogin } from "./migrate.js";
13
13
  import { createRuns } from "./runs.js";
14
+ import { outputSchema } from "./output.js";
14
15
  import { createKeys } from "./keys.js";
15
16
  import { routes as routeTable } from "./routes.js";
16
17
  import { providers as engineProviders, signIn as startSignIn, signOut as engineSignOut } from "./signin.js";
@@ -640,6 +641,14 @@ export class OpenClawKit {
640
641
  run(spec, on) {
641
642
  if (spec.auth !== undefined && spec.auth !== 'apiKey')
642
643
  return Promise.reject(new Error('Choose a supported account option.'));
644
+ // Snapshot before account readiness awaits; invalid schemas never reach the Gateway.
645
+ let output;
646
+ try {
647
+ output = spec.schema === undefined ? undefined : outputSchema(spec.schema);
648
+ }
649
+ catch (error) {
650
+ return Promise.reject(error);
651
+ }
643
652
  if (spec.auth === 'apiKey')
644
653
  return this.keys.exclusive(spec.member, async () => {
645
654
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
@@ -650,9 +659,9 @@ export class OpenClawKit {
650
659
  if (spec.model !== undefined && spec.model !== selected.model)
651
660
  return { ok: false, kind: 'plan', message: 'Choose the model saved with this key.' };
652
661
  // RunRef.member stays the person for app gates; the engine receives the isolated agent and history.
653
- return this.runs().run({ ...spec, model: selected.model }, on, selected.agentId);
662
+ return this.runs().run({ ...spec, model: selected.model }, on, selected.agentId, output);
654
663
  });
655
- return this.runs().run(spec, on);
664
+ return this.runs().run(spec, on, undefined, output);
656
665
  }
657
666
  runKey(sessionKey, o) {
658
667
  if (o?.auth !== 'apiKey')
package/dist/link.js CHANGED
@@ -10,6 +10,7 @@ import { ENGINE_VERSION } from "./constants.js";
10
10
  import { b64urlDecode, sealNotice } from "./notices.js";
11
11
  import { routeFor } from "./routes.js";
12
12
  import { authStatus } from "./auth-status.js";
13
+ import { outputSchema } from "./output.js";
13
14
  import { signedInProviders } from "./runs.js";
14
15
  import { stateWords, words } from "./words.js";
15
16
  const VIEW_OPS = new Set(['oc.state', 'oc.routes', 'oc.signin.view', 'oc.sessions', 'oc.approvals', 'oc.events']);
@@ -25,7 +26,7 @@ function memberSessions(result, member) {
25
26
  }
26
27
  /** `oc.run`'s run options, type-checked; the kit's own checks (account, tool names) still apply. */
27
28
  function runOptions(args) {
28
- const { message, model, auth, system, images, thinking, tools } = args;
29
+ const { message, model, auth, system, images, thinking, tools, schema } = args;
29
30
  const ok = typeof message === 'string'
30
31
  && (model === undefined || typeof model === 'string')
31
32
  && (auth === undefined || auth === 'apiKey')
@@ -34,9 +35,12 @@ function runOptions(args) {
34
35
  && (thinking === undefined || (typeof thinking === 'string' && THINKING.has(thinking)))
35
36
  && (tools === undefined || (Array.isArray(tools) && tools.every((t) => typeof t === 'string')));
36
37
  if (!ok)
37
- throw new Error('oc.run needs { message, sessionKey?, model?, system?, images?, thinking?, tools? }');
38
+ throw new Error('oc.run needs { message, sessionKey?, model?, auth?, system?, images?, thinking?, tools?, schema? }');
39
+ if (schema !== undefined)
40
+ outputSchema(schema);
38
41
  return {
39
42
  message: message,
43
+ ...(schema === undefined ? {} : { schema: schema }),
40
44
  ...(model === undefined ? {} : { model: model }),
41
45
  ...(auth === undefined ? {} : { auth: 'apiKey' }),
42
46
  ...(system === undefined ? {} : { system: system }),
@@ -0,0 +1,68 @@
1
+ export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
2
+ readonly [key: string]: JsonValue;
3
+ };
4
+ type JsonType = 'null' | 'boolean' | 'number' | 'integer' | 'string' | 'array' | 'object';
5
+ type SchemaNode = boolean | OutputSchema;
6
+ export type OutputSchema = {
7
+ readonly type?: JsonType | readonly JsonType[];
8
+ readonly properties?: Readonly<Record<string, SchemaNode>>;
9
+ readonly required?: readonly string[];
10
+ readonly additionalProperties?: SchemaNode;
11
+ readonly items?: SchemaNode;
12
+ readonly enum?: readonly JsonValue[];
13
+ readonly const?: JsonValue;
14
+ readonly anyOf?: readonly SchemaNode[];
15
+ readonly oneOf?: readonly SchemaNode[];
16
+ readonly allOf?: readonly SchemaNode[];
17
+ readonly not?: SchemaNode;
18
+ readonly minimum?: number;
19
+ readonly maximum?: number;
20
+ readonly exclusiveMinimum?: number;
21
+ readonly exclusiveMaximum?: number;
22
+ readonly minLength?: number;
23
+ readonly maxLength?: number;
24
+ readonly minItems?: number;
25
+ readonly maxItems?: number;
26
+ readonly uniqueItems?: boolean;
27
+ readonly minProperties?: number;
28
+ readonly maxProperties?: number;
29
+ readonly title?: string;
30
+ readonly description?: string;
31
+ readonly default?: JsonValue;
32
+ readonly examples?: readonly JsonValue[];
33
+ readonly $schema?: 'http://json-schema.org/draft-07/schema#';
34
+ };
35
+ type KeysInEveryList<R extends readonly string[]> = (R extends unknown ? (key: number extends R['length'] ? never : R[number]) => void : never) extends (key: infer K) => void ? Extract<K, string> : never;
36
+ type RequiredKeys<S> = S extends {
37
+ readonly required: infer R extends readonly string[];
38
+ } ? KeysInEveryList<R> : never;
39
+ type ObjectOutput<S> = S extends {
40
+ readonly properties: infer P extends Record<string, SchemaNode>;
41
+ } ? {
42
+ -readonly [K in keyof P as K extends RequiredKeys<S> ? K : never]: SchemaOutput<P[K]>;
43
+ } & {
44
+ -readonly [K in keyof P as K extends RequiredKeys<S> ? never : K]?: SchemaOutput<P[K]>;
45
+ } : Record<string, unknown>;
46
+ type OutputType<T, S> = T extends 'null' ? null : T extends 'boolean' ? boolean : T extends 'number' | 'integer' ? number : T extends 'string' ? string : T extends 'array' ? (S extends {
47
+ readonly items: infer I;
48
+ } ? SchemaOutput<I> : unknown)[] : T extends 'object' ? ObjectOutput<S> : unknown;
49
+ /** Literal schemas infer their output; broad/dynamic schemas and unsupported inference shapes remain unknown. */
50
+ export type SchemaOutput<S> = S extends {
51
+ readonly const: infer V;
52
+ } ? V : S extends {
53
+ readonly enum: readonly (infer V)[];
54
+ } ? V : S extends {
55
+ readonly type: infer T;
56
+ } ? OutputType<T extends readonly unknown[] ? T[number] : T, S> : S extends {
57
+ readonly anyOf: readonly (infer B)[];
58
+ } | {
59
+ readonly oneOf: readonly (infer B)[];
60
+ } ? SchemaOutput<B> : unknown;
61
+ /** Snapshot a JSON-only schema before any await; no mutation can change the prompt or its validation. */
62
+ export declare function outputSchema(value: unknown): {
63
+ prompt: string;
64
+ parse(text: string): {
65
+ data: unknown;
66
+ } | undefined;
67
+ };
68
+ export {};
package/dist/output.js ADDED
@@ -0,0 +1,166 @@
1
+ const record = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
2
+ const own = (v, k) => Object.hasOwn(v, k);
3
+ const types = new Set(['null', 'boolean', 'number', 'integer', 'string', 'array', 'object']);
4
+ const bounds = ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum'];
5
+ const counts = ['minLength', 'maxLength', 'minItems', 'maxItems', 'minProperties', 'maxProperties'];
6
+ const annotations = ['title', 'description', 'default', 'examples', '$schema'];
7
+ const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const',
8
+ 'anyOf', 'oneOf', 'allOf', 'not', 'uniqueItems', ...bounds, ...counts, ...annotations]);
9
+ function invalid() { throw new Error('refused: invalid or unsupported output schema'); }
10
+ function checkSchema(s, depth = 0) {
11
+ if (depth > 32)
12
+ invalid();
13
+ if (typeof s === 'boolean')
14
+ return;
15
+ if (!record(s) || Object.keys(s).some((k) => !keywords.has(k)))
16
+ invalid();
17
+ if (own(s, 'type')) {
18
+ const ts = Array.isArray(s.type) ? s.type : [s.type];
19
+ if (!ts.length || ts.some((t) => !types.has(t)) || new Set(ts).size !== ts.length)
20
+ invalid();
21
+ }
22
+ for (const k of bounds)
23
+ if (own(s, k) && (typeof s[k] !== 'number' || !Number.isFinite(s[k])))
24
+ invalid();
25
+ for (const k of counts)
26
+ if (own(s, k) && (typeof s[k] !== 'number' || !Number.isSafeInteger(s[k]) || s[k] < 0))
27
+ invalid();
28
+ for (const k of ['title', 'description'])
29
+ if (own(s, k) && typeof s[k] !== 'string')
30
+ invalid();
31
+ if (own(s, '$schema') && s.$schema !== 'http://json-schema.org/draft-07/schema#')
32
+ invalid();
33
+ if (own(s, 'examples') && !Array.isArray(s.examples))
34
+ invalid();
35
+ if (own(s, 'enum') && (!Array.isArray(s.enum) || !s.enum.length))
36
+ invalid();
37
+ if (own(s, 'uniqueItems') && typeof s.uniqueItems !== 'boolean')
38
+ invalid();
39
+ if (own(s, 'required') && (!Array.isArray(s.required) || s.required.some((k) => typeof k !== 'string')
40
+ || new Set(s.required).size !== s.required.length))
41
+ invalid();
42
+ if (own(s, 'properties')) {
43
+ if (!record(s.properties))
44
+ invalid();
45
+ for (const sub of Object.values(s.properties))
46
+ checkSchema(sub, depth + 1);
47
+ }
48
+ for (const k of ['items', 'additionalProperties', 'not'])
49
+ if (own(s, k))
50
+ checkSchema(s[k], depth + 1);
51
+ for (const k of ['anyOf', 'oneOf', 'allOf'])
52
+ if (own(s, k)) {
53
+ if (!Array.isArray(s[k]) || !s[k].length)
54
+ invalid();
55
+ for (const sub of s[k])
56
+ checkSchema(sub, depth + 1);
57
+ }
58
+ }
59
+ // Equality is structural: JSON objects have no key order, including enum/const values and unique array items.
60
+ function equal(a, b) {
61
+ if (a === b)
62
+ return true;
63
+ if (Array.isArray(a) && Array.isArray(b))
64
+ return a.length === b.length && a.every((v, i) => equal(v, b[i]));
65
+ if (record(a) && record(b))
66
+ return Object.keys(a).length === Object.keys(b).length
67
+ && Object.keys(a).every((k) => own(b, k) && equal(a[k], b[k]));
68
+ return false;
69
+ }
70
+ function hasType(v, t) {
71
+ if (t === 'null')
72
+ return v === null;
73
+ if (t === 'array')
74
+ return Array.isArray(v);
75
+ if (t === 'object')
76
+ return record(v);
77
+ if (t === 'integer')
78
+ return typeof v === 'number' && Number.isInteger(v);
79
+ return typeof v === t && (t !== 'number' || Number.isFinite(v));
80
+ }
81
+ function matches(s, v) {
82
+ if (typeof s === 'boolean')
83
+ return s;
84
+ if (s.type !== undefined && !(Array.isArray(s.type) ? s.type : [s.type]).some((t) => hasType(v, t)))
85
+ return false;
86
+ if (own(s, 'const') && !equal(s.const, v))
87
+ return false;
88
+ if (s.enum && !s.enum.some((e) => equal(e, v)))
89
+ return false;
90
+ if (s.allOf && !s.allOf.every((sub) => matches(sub, v)))
91
+ return false;
92
+ if (s.anyOf && !s.anyOf.some((sub) => matches(sub, v)))
93
+ return false;
94
+ if (s.oneOf && s.oneOf.filter((sub) => matches(sub, v)).length !== 1)
95
+ return false;
96
+ if (s.not !== undefined && matches(s.not, v))
97
+ return false;
98
+ if (typeof v === 'number') {
99
+ if (!Number.isFinite(v) || (s.minimum !== undefined && v < s.minimum) || (s.maximum !== undefined && v > s.maximum)
100
+ || (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) || (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum))
101
+ return false;
102
+ }
103
+ if (typeof v === 'string') {
104
+ const length = [...v].length;
105
+ if ((s.minLength !== undefined && length < s.minLength) || (s.maxLength !== undefined && length > s.maxLength))
106
+ return false;
107
+ }
108
+ if (Array.isArray(v)) {
109
+ if ((s.minItems !== undefined && v.length < s.minItems) || (s.maxItems !== undefined && v.length > s.maxItems))
110
+ return false;
111
+ if (s.items !== undefined && !v.every((item) => matches(s.items, item)))
112
+ return false;
113
+ if (s.uniqueItems && v.some((item, i) => v.slice(0, i).some((other) => equal(item, other))))
114
+ return false;
115
+ }
116
+ if (record(v)) {
117
+ const keys = Object.keys(v);
118
+ if ((s.minProperties !== undefined && keys.length < s.minProperties) || (s.maxProperties !== undefined && keys.length > s.maxProperties))
119
+ return false;
120
+ if (s.required?.some((k) => !own(v, k)))
121
+ return false;
122
+ for (const k of keys) {
123
+ const sub = s.properties && own(s.properties, k) ? s.properties[k] : s.additionalProperties;
124
+ if (sub !== undefined && !matches(sub, v[k]))
125
+ return false;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+ /** Snapshot a JSON-only schema before any await; no mutation can change the prompt or its validation. */
131
+ export function outputSchema(value) {
132
+ let json;
133
+ try {
134
+ json = JSON.stringify(value, (_key, v) => {
135
+ if (v === undefined || typeof v === 'function' || typeof v === 'symbol' || typeof v === 'bigint'
136
+ || (typeof v === 'number' && !Number.isFinite(v)))
137
+ invalid();
138
+ return v;
139
+ });
140
+ }
141
+ catch {
142
+ invalid();
143
+ }
144
+ if (!json || json.length > 65_536 || new TextEncoder().encode(json).length > 65_536)
145
+ invalid();
146
+ const schema = JSON.parse(json);
147
+ if (!record(schema))
148
+ invalid(); // the public run contract requires an object at its root
149
+ checkSchema(schema);
150
+ return {
151
+ prompt: `Return only one JSON value matching this JSON Schema, with no markdown or surrounding text.\n${json}`,
152
+ parse: (text) => {
153
+ try {
154
+ const data = JSON.parse(text, (_key, value) => {
155
+ if (typeof value === 'number' && !Number.isFinite(value))
156
+ throw new Error('invalid number');
157
+ return value;
158
+ });
159
+ return matches(schema, data) ? { data } : undefined;
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ },
165
+ };
166
+ }
package/dist/runs.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Bridge } from './bridge.ts';
2
+ import { outputSchema, type OutputSchema, type SchemaOutput } from './output.ts';
2
3
  import type { GatewayTransport, Member, RunEnd, RunEvent, RunSpec } from './types.ts';
3
4
  /**
4
5
  * The providers a `models.authStatus` answer shows usable (the rule a picked account is checked by), lowercased and
@@ -14,7 +15,7 @@ export declare function createRuns(ctx: {
14
15
  bridge: Pick<Bridge, 'register'>;
15
16
  tools: ReadonlySet<string>;
16
17
  }): {
17
- run(spec: RunSpec, on?: (e: RunEvent) => void, keyAgent?: string): Promise<RunEnd>;
18
+ run<const S extends OutputSchema | undefined = undefined>(spec: RunSpec<S>, on?: (e: RunEvent) => void, keyAgent?: string, preparedOutput?: ReturnType<typeof outputSchema>): Promise<RunEnd<SchemaOutput<S>>>;
18
19
  steer(k: string, t: string): Promise<void>;
19
20
  abort(k: string): Promise<void>;
20
21
  };
package/dist/runs.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { authStatus } from "./auth-status.js";
5
+ import { outputSchema } from "./output.js";
6
+ import { words } from "./words.js";
5
7
  import { classify } from "./classify.js";
6
8
  // The gateway gives up on a silent run after an hour; we wait ten minutes longer for the reply frame itself.
7
9
  const WAIT_MS = 3_600_000;
@@ -78,10 +80,12 @@ export function createRuns(ctx) {
78
80
  return undefined;
79
81
  }
80
82
  };
81
- const run = async (spec, on, keyAgent) => {
83
+ const run = async (spec, on, keyAgent, preparedOutput) => {
82
84
  // Member boundary first: a member never speaks in another member's session, refused before any request.
83
85
  if (!spec.sessionKey.startsWith(`agent:${spec.member}:`))
84
86
  throw new Error(`refused: "${spec.sessionKey}" is not a session of member "${spec.member}"`);
87
+ const output = preparedOutput ?? (spec.schema === undefined ? undefined : outputSchema(spec.schema));
88
+ const system = [spec.system, output?.prompt].filter(Boolean).join('\n\n');
85
89
  const picked = spec.model === undefined ? undefined : account(spec.model);
86
90
  for (const tool of spec.tools ?? [])
87
91
  if (!ctx.tools.has(tool))
@@ -89,7 +93,7 @@ export function createRuns(ctx) {
89
93
  const agentId = keyAgent ?? (await ctx.ensure(spec.member)).agentId;
90
94
  const sessionKey = keyAgent ? `agent:${keyAgent}:${spec.sessionKey.slice(`agent:${spec.member}:`.length)}` : spec.sessionKey;
91
95
  const release = spec.register !== false ? ctx.bridge.register({ sessionKey, member: spec.member }, spec.tools) : undefined;
92
- let last = '';
96
+ let last;
93
97
  let runId = ''; // gateway events for other runs carry a real runId and never match the empty one
94
98
  let ended = false;
95
99
  // The engine flags an aborted run on its lifecycle end event; the wait receipt itself only says
@@ -153,7 +157,7 @@ export function createRuns(ctx) {
153
157
  message: spec.message,
154
158
  idempotencyKey: randomUUID(),
155
159
  ...(picked ?? {}),
156
- ...(spec.system ? { extraSystemPrompt: spec.system } : {}),
160
+ ...(system ? { extraSystemPrompt: system } : {}),
157
161
  ...(spec.images ? { attachments: spec.images.map((image) => ({ mimeType: image.mimeType, content: image.data })) } : {}),
158
162
  ...(spec.thinking ? { thinking: spec.thinking } : {}),
159
163
  }, { expectFinal: true, timeoutMs: WAIT_CLIENT_MS, onAccepted });
@@ -162,15 +166,28 @@ export function createRuns(ctx) {
162
166
  runId = started.runId;
163
167
  const result = await ctx.request('agent.wait', { runId, timeoutMs: WAIT_MS }, { timeoutMs: WAIT_CLIENT_MS });
164
168
  if (result.status === 'ok') {
165
- const text = typeof result.terminalReply?.text === 'string' ? result.terminalReply.text : last;
166
- on?.({ type: 'text', text }); // the final cumulative text
167
169
  const done = await Promise.race([final.catch(() => undefined), delay(FINAL_GRACE_MS, undefined, { ref: false })]);
168
- const meta = isRecord(done) && isRecord(done.result) && isRecord(done.result.meta) ? done.result.meta : {};
170
+ ended = true; // no later stream event may supersede the final callback
171
+ const frame = isRecord(done) && isRecord(done.result) ? done.result : {};
172
+ const payloadText = Array.isArray(frame.payloads)
173
+ ? frame.payloads.filter((p) => isRecord(p) && typeof p.text === 'string')
174
+ .map((p) => p.text) : [];
175
+ // The pin's terminal snapshot is capped display evidence, not the complete generated answer.
176
+ // Its silent/empty disposition still controls visibility, even after transient streamed text.
177
+ const terminal = result.terminalReply;
178
+ const text = terminal?.disposition === 'silent' || terminal?.disposition === 'empty' ? ''
179
+ : payloadText.length ? payloadText.join('\n\n')
180
+ : last ?? (typeof terminal?.text === 'string' ? terminal.text : '');
181
+ on?.({ type: 'text', text }); // the final cumulative text (unvalidated)
182
+ const parsed = output?.parse(text);
183
+ if (output && !parsed)
184
+ return { ok: false, kind: 'output', message: words('member.output') };
185
+ const meta = isRecord(frame.meta) ? frame.meta : {};
169
186
  const agentMeta = isRecord(meta.agentMeta) ? meta.agentMeta : {};
170
187
  const usage = usageOf(agentMeta);
171
188
  const planWindow = typeof agentMeta.provider === 'string'
172
189
  ? await planWindowOf(agentId, agentMeta.provider.toLowerCase()) : undefined;
173
- return { ok: true, text, ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
190
+ return { ok: true, text, ...(parsed ? { data: parsed.data } : {}), ...(usage ? { usage } : {}), ...(planWindow ? { planWindow } : {}) };
174
191
  }
175
192
  if (result.stopReason === 'aborted' || (abortedByEngine && result.status !== 'ok'))
176
193
  return { ok: false, aborted: true };
package/dist/types.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { OutputSchema } from './output.ts';
2
+ export type { OutputSchema, SchemaOutput } from './output.ts';
1
3
  export type Member = string;
2
4
  export interface ToolSpec {
3
5
  name: string;
@@ -25,8 +27,9 @@ export interface ToolHost {
25
27
  }): Promise<GateResult>;
26
28
  call(run: RunRef, tool: string, input: Record<string, unknown>, signal: AbortSignal): Promise<string>;
27
29
  }
28
- export interface RunSpec extends RunRef {
30
+ export interface RunSpec<S extends OutputSchema | undefined = OutputSchema | undefined> extends RunRef {
29
31
  message: string;
32
+ schema?: S;
30
33
  system?: string;
31
34
  images?: {
32
35
  data: string;
@@ -71,9 +74,10 @@ export type PlanWindow = {
71
74
  resetAt?: number;
72
75
  }[];
73
76
  };
74
- export type RunEnd = {
77
+ export type RunEnd<T = unknown> = {
75
78
  ok: true;
76
79
  text: string;
80
+ data?: T;
77
81
  usage?: RunUsage;
78
82
  planWindow?: PlanWindow;
79
83
  } | {
@@ -81,7 +85,7 @@ export type RunEnd = {
81
85
  aborted: true;
82
86
  } | {
83
87
  ok: false;
84
- kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other';
88
+ kind: 'signed-out' | 'resting' | 'plan' | 'network' | 'other' | 'output';
85
89
  until?: number;
86
90
  message: string;
87
91
  };
package/dist/types.js CHANGED
@@ -1,2 +1 @@
1
- // Public types for the OpenClaw runtime kit (docs/runtime-kits.md 5.2). Types only; behavior lives in the modules.
2
1
  export {};
package/dist/words.json CHANGED
@@ -11,6 +11,7 @@
11
11
  "member.signedOut": "Sign in with {name} to start.",
12
12
  "member.resting": "{name} needs a break until {time}.",
13
13
  "member.plan": "Your {name} plan doesn't include this.",
14
+ "member.output": "The answer did not match the requested format.",
14
15
  "member.network": "Can't reach {name} right now. This keeps trying by itself.",
15
16
  "signin.returned": "Thanks. Finishing the sign-in — you can go back to the app now.",
16
17
  "signin.busy": "Another sign-in is already in progress. Finish or cancel it, then try again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/openclaw",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The OpenClaw runtime kit: the pinned engine's full operator surface with plain-words helpers for members, sign-in, runs and approvals.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@byokit/link": "0.7.0",
56
- "@byokit/relay": "0.5.0",
56
+ "@byokit/relay": "0.5.1",
57
57
  "@byokit/reach": "0.6.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "@byokit/secrets": "0.5.0",