@nxisai/agent-harness 0.8.0 → 0.9.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.
Files changed (40) hide show
  1. package/dist/a2ui/cards.d.ts +3 -0
  2. package/dist/a2ui/cards.d.ts.map +1 -1
  3. package/dist/a2ui/cards.js +11 -2
  4. package/dist/a2ui/cards.js.map +1 -1
  5. package/dist/harness/cta.d.ts +27 -0
  6. package/dist/harness/cta.d.ts.map +1 -0
  7. package/dist/harness/cta.js +43 -0
  8. package/dist/harness/cta.js.map +1 -0
  9. package/dist/harness/harness.d.ts +17 -0
  10. package/dist/harness/harness.d.ts.map +1 -1
  11. package/dist/harness/harness.js +40 -0
  12. package/dist/harness/harness.js.map +1 -1
  13. package/dist/harness/router/coordinator.d.ts +6 -0
  14. package/dist/harness/router/coordinator.d.ts.map +1 -1
  15. package/dist/harness/router/coordinator.js +14 -0
  16. package/dist/harness/router/coordinator.js.map +1 -1
  17. package/dist/harness/tools/action-tools.d.ts +15 -0
  18. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  19. package/dist/harness/tools/action-tools.js +21 -0
  20. package/dist/harness/tools/action-tools.js.map +1 -1
  21. package/dist/harness/tools/inbound.d.ts +88 -0
  22. package/dist/harness/tools/inbound.d.ts.map +1 -0
  23. package/dist/harness/tools/inbound.js +156 -0
  24. package/dist/harness/tools/inbound.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +3 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  30. package/dist/ui/agent-fullscreen.js +78 -12
  31. package/dist/ui/agent-fullscreen.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/a2ui/cards.ts +14 -2
  34. package/src/harness/cta.ts +54 -0
  35. package/src/harness/harness.ts +42 -0
  36. package/src/harness/router/coordinator.ts +14 -0
  37. package/src/harness/tools/action-tools.ts +23 -0
  38. package/src/harness/tools/inbound.ts +189 -0
  39. package/src/index.ts +13 -0
  40. package/src/ui/agent-fullscreen.ts +72 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxisai/agent-harness",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "An on-device agent for the browser: a tool-calling harness over WebMCP and remote MCP, plus a drop-in chat UI. Decides whether to run a tool, ask for what's missing, or confirm before writing.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/a2ui/cards.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { A2uiMessage } from './emit.js';
15
+ import { actionCta, type CardAction } from '../harness/cta.js';
15
16
 
16
17
  const CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json';
17
18
 
@@ -27,6 +28,8 @@ export interface ResultCardData {
27
28
  sub: string;
28
29
  tags: CardTag[];
29
30
  rows: CardRow[];
31
+ /** The record's own action, as at most one button. A list so a card without one draws nothing. */
32
+ actions: CardAction[];
30
33
  }
31
34
 
32
35
  const str = (v: unknown): string => (v == null ? '' : String(Array.isArray(v) ? v[0] : v));
@@ -93,7 +96,8 @@ export function toCardData(entity: Record<string, unknown>): ResultCardData {
93
96
  }
94
97
  }
95
98
 
96
- return { title, sub, tags, rows };
99
+ const action = actionCta(entity, title);
100
+ return { title, sub, tags, rows, actions: action ? [action] : [] };
97
101
  }
98
102
 
99
103
  /** Fixed component set — independent of how many results there are. */
@@ -106,7 +110,7 @@ function cardComponents(heading: string): Component[] {
106
110
  { id: 'results', component: 'List',
107
111
  children: { path: '/results', componentId: 'card_tpl' }, direction: 'vertical' },
108
112
  { id: 'card_tpl', component: 'Card', child: 'card_body' },
109
- { id: 'card_body', component: 'Column', children: ['card_title', 'card_tags', 'card_sub', 'card_rows'] },
113
+ { id: 'card_body', component: 'Column', children: ['card_title', 'card_tags', 'card_sub', 'card_rows', 'card_actions'] },
110
114
  // No bold: `weight` in this catalog is a flex weight (number), and Text
111
115
  // offers only variant caption/body. Title emphasis is not expressible in
112
116
  // the basic catalog: it needs a custom catalog or renderer styling.
@@ -124,6 +128,14 @@ function cardComponents(heading: string): Component[] {
124
128
  { id: 'row_tpl', component: 'Row', children: ['row_label', 'row_value'] },
125
129
  { id: 'row_label', component: 'Text', text: '${label}', variant: 'caption' },
126
130
  { id: 'row_value', component: 'Text', text: '${value}', variant: 'caption' },
131
+
132
+ // The record's action: zero or one per card, so a card without one draws nothing. Pressing it
133
+ // dispatches nxis.ask with the message to send as the visitor; it never submits anything itself.
134
+ { id: 'card_actions', component: 'List',
135
+ children: { path: 'actions', componentId: 'action_tpl' }, direction: 'horizontal' },
136
+ { id: 'action_tpl', component: 'Button', child: 'action_label', variant: 'primary',
137
+ action: { event: { name: 'nxis.ask', context: { message: '${message}' } } } },
138
+ { id: 'action_label', component: 'Text', text: '${label}', variant: 'body' },
127
139
  ];
128
140
  }
129
141
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The action a result card offers, chosen from the record's own `potentialAction`.
3
+ *
4
+ * One map, shared with the Typed Retrieval skill (nxis-skills) and the NxisAI app's widgets (nxis-mcp
5
+ * `HubWidgets`): the action's `@type` alone decides the button, and the button sends the same prompt
6
+ * everywhere ("Get a quote from Hatzel & Sons Plumbing"), so the funnel cannot tell which surface it came
7
+ * from. Commerce, read and search actions get no button here.
8
+ *
9
+ * On the site's own page every mapped action sends the prompt, a self-page form included: the on-page agent
10
+ * is what runs that form. Only the NxisAI app, which cannot reach the page, opens it instead.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /** What a card's action button says and sends. */
16
+ export interface CardAction {
17
+ label: string;
18
+ /** The message sent as the visitor when the button is pressed. */
19
+ message: string;
20
+ }
21
+
22
+ const MAP: Record<string, { label: string; message: (name: string) => string }> = {
23
+ QuoteAction: { label: 'Get a quote', message: (n) => `Get a quote from ${n}` },
24
+ AskAction: { label: 'Contact', message: (n) => `Contact ${n}` },
25
+ ReserveAction: { label: 'Reserve', message: (n) => `Reserve at ${n}` },
26
+ ScheduleAction: { label: 'Book an appointment', message: (n) => `Book an appointment at ${n}` },
27
+ ApplyAction: { label: 'Apply', message: (n) => `Apply to ${n}` },
28
+ };
29
+
30
+ /** When a record carries several, the first of these wins. */
31
+ const ORDER = Object.keys(MAP);
32
+
33
+ const shortType = (t: unknown): string =>
34
+ typeof t === 'string' ? t.replace(/^https?:\/\/schema\.org\//, '') : '';
35
+
36
+ const list = (v: unknown): unknown[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
37
+
38
+ /**
39
+ * The button for a record, or none.
40
+ *
41
+ * @param record - The record as a tool returned it; action `@type`s may be full IRIs.
42
+ * @param name - What to call it in the message; the record's own name by default.
43
+ */
44
+ export function actionCta(record: Record<string, unknown>, name?: string): CardAction | undefined {
45
+ const types = list(record.potentialAction)
46
+ .filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
47
+ .flatMap((a) => list(a['@type']).map(shortType))
48
+ .filter((t) => t in MAP)
49
+ .sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
50
+ if (!types.length) return undefined;
51
+ const who = (name ?? (typeof record.name === 'string' ? record.name : '')).trim() || 'them';
52
+ const entry = MAP[types[0]];
53
+ return { label: entry.label, message: entry.message(who) };
54
+ }
@@ -575,6 +575,48 @@ export class AgentHarness {
575
575
  return this.act(tool, call.args, messages);
576
576
  }
577
577
 
578
+ /**
579
+ * Run a turn whose tool call is already decided, without asking the model.
580
+ *
581
+ * For a request that names its own action, such as a link from the NxisAI
582
+ * app to one business's quote form: the tool and the business are known, so
583
+ * the model has nothing to choose, and a model left to choose sometimes
584
+ * asks for the details in prose instead of opening the form. What follows
585
+ * the call is an ordinary turn's: the same slots, prompts and confirmation,
586
+ * and the call goes into the transcript as if the model had made it.
587
+ *
588
+ * @param goal - The request as the person would have said it, for the thread and transcript.
589
+ * @param name - The tool to call.
590
+ * @param args - Its arguments, e.g. which business.
591
+ * @param messages - The live conversation, appended to in place.
592
+ * @returns The decision taken, or `'unavailable'` when this agent has no such tool.
593
+ */
594
+ async begin(goal: string, name: string, args: Record<string, unknown>, messages: ChatMessage[]): Promise<TurnResult> {
595
+ this.abort?.abort();
596
+ this.abort = new AbortController();
597
+ this.goal = goal;
598
+ this.renderer.stoppable?.(true);
599
+ this.applySystemPrompt(messages);
600
+ beginJourney();
601
+
602
+ const bubble = this.renderer.streaming();
603
+ bubble.thinking('Finding tools…');
604
+ const tools = this.visibleTools(await this.toolSource.discoverTools(this.abort.signal), goal);
605
+ if (this.stopped) return this.stopTurn(bubble, messages, goal);
606
+ const tool = tools.find((t) => t.name === name);
607
+ if (!tool) {
608
+ bubble.finalize('');
609
+ return this.finished({ decision: 'unavailable' as const });
610
+ }
611
+
612
+ const call = { name, args };
613
+ messages.push({ role: 'user', content: goal });
614
+ bubble.finalize(narrateCall(call, tools));
615
+ messages.push({ role: 'assistant', content: this.transcribe({ text: '', call } as Completion) });
616
+ this.opts.onMessages?.(messages);
617
+ return this.act(tool, args, messages);
618
+ }
619
+
578
620
  /**
579
621
  * Narrow the page's tools to this agent's slice.
580
622
  *
@@ -188,6 +188,20 @@ export class AgentCoordinator {
188
188
  return this.working.turn(goal, messages);
189
189
  }
190
190
 
191
+ /**
192
+ * Run a turn whose tool call is already decided, on the agent that holds the tool.
193
+ *
194
+ * @see AgentHarness.begin
195
+ */
196
+ async begin(goal: string, name: string, args: Record<string, unknown>, messages: ChatMessage[]): Promise<TurnResult> {
197
+ const owner = this.ownerOf(name, '');
198
+ if (!owner) return { decision: 'unavailable' };
199
+ this.opts.onRoute?.({ ...this.route(goal, messages), agent: owner, via: 'handback', fellBack: false });
200
+ this.sticky.set(messages, owner);
201
+ this.working = this.opts.agents[owner];
202
+ return this.working.begin(goal, name, args, messages);
203
+ }
204
+
191
205
  /** The agent holding a tool, ignoring the one that handed the turn back. */
192
206
  private ownerOf(tool: string, except: string): string | null {
193
207
  for (const [name, agent] of Object.entries(this.opts.agents)) {
@@ -538,6 +538,29 @@ export function formsFor(name: string): { tool: string; noun: string }[] {
538
538
  return out;
539
539
  }
540
540
 
541
+ /**
542
+ * The site form that runs an action, by the action's `@id`: the site-wide tool
543
+ * built on this page that covers it, and which business it is for.
544
+ *
545
+ * @returns The tool's name, what the form is called ("quote form"), the
546
+ * choice that picks this action when the tool covers several, and the
547
+ * arguments that make a call to it this action; null when no site tool built
548
+ * here covers it.
549
+ */
550
+ export function siteFormFor(
551
+ actionId: string,
552
+ ): { tool: string; noun: string; choice?: string; args: Record<string, unknown> } | null {
553
+ for (const [toolName, t] of built) {
554
+ const index = t.actions.findIndex((a) => a['@id'] === actionId);
555
+ if (index < 0) continue;
556
+ const noun = withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase();
557
+ const choice = t.choices?.[index];
558
+ const args = choice && t.discriminator ? { [t.discriminator]: choice } : {};
559
+ return choice ? { tool: toolName, noun, choice, args } : { tool: toolName, noun, args };
560
+ }
561
+ return null;
562
+ }
563
+
541
564
  /** An option that only asks to choose: "Select…", "Choose a service…". */
542
565
  export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
543
566
 
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Arriving from somewhere else to use one of this page's forms.
3
+ *
4
+ * The NxisAI app in Claude or ChatGPT cannot fill a form that runs in the
5
+ * browser, so for such an action it sends the person to the page instead:
6
+ * `?nxis_handoff=<the action's @id>`, and optionally `&nxis_intent=<what they
7
+ * want>`. On arrival the agent opens on that form.
8
+ *
9
+ * In the query, so the fragment, which some sites route by, is left alone. A
10
+ * page's structured data is found by its registered address, so the site tag
11
+ * leaves these two parameters out of the address it asks for.
12
+ *
13
+ * A link can be written by anyone, so it only ever chooses which of the page's
14
+ * own forms to start. The words that start it are the agent's own, built from
15
+ * the form; the intent is left in the composer for the person to send, change
16
+ * or delete, never sent on their behalf. Neither parameter carries anything
17
+ * personal, and both are taken out of the address bar once read.
18
+ *
19
+ * @module
20
+ */
21
+ import { awaitPageTool, withoutSiteSuffix } from './action-tools.js';
22
+ import { waitForModelContext } from './source.js';
23
+ import type { ModelContext, ModelContextToolInfo } from '../../types.js';
24
+
25
+ /** The action to open on: its `@id`. */
26
+ export const HANDOFF_PARAM = 'nxis_handoff';
27
+ /** What the person wants, in a few words. Optional. */
28
+ export const INTENT_PARAM = 'nxis_intent';
29
+
30
+ /** Longer than any `@id` the crawler writes; a longer value is not one. */
31
+ const MAX_ID = 512;
32
+ /** A few words: enough for "a quote for a panel upgrade", not a message. */
33
+ export const MAX_INTENT = 120;
34
+
35
+ /** How long to wait for the registry, and then for the form's own tool. */
36
+ const WAIT_MS = 8000;
37
+
38
+ /** A handoff read from the address. */
39
+ export interface InboundHandoff {
40
+ /** The `@id` of the action whose form to open. */
41
+ actionId: string;
42
+ /** What the person wants, cleaned up; absent when none was given. */
43
+ intent?: string;
44
+ }
45
+
46
+ /**
47
+ * The handoff in an address, if there is one.
48
+ *
49
+ * @param href - The page's address.
50
+ * @returns The handoff, or null when there is none or it is malformed.
51
+ */
52
+ export function readInboundHandoff(href: string): InboundHandoff | null {
53
+ let url: URL;
54
+ try {
55
+ url = new URL(href);
56
+ } catch {
57
+ return null;
58
+ }
59
+ const actionId = (url.searchParams.get(HANDOFF_PARAM) ?? '').trim();
60
+ if (!actionId || actionId.length > MAX_ID || /\s/.test(actionId)) return null;
61
+ const intent = cleanIntent(url.searchParams.get(INTENT_PARAM) ?? '');
62
+ return intent ? { actionId, intent } : { actionId };
63
+ }
64
+
65
+ /** One line of plain text, at most {@link MAX_INTENT} characters. */
66
+ export function cleanIntent(raw: string): string {
67
+ // Control characters, newlines included, become spaces: a draft is one line.
68
+ const flat = raw.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
69
+ return flat.length > MAX_INTENT ? flat.slice(0, MAX_INTENT - 1).trimEnd() + '…' : flat;
70
+ }
71
+
72
+ /**
73
+ * The address without the handoff parameters, everything else kept.
74
+ *
75
+ * A reload, a bookmark or a copied link would otherwise open the form again.
76
+ */
77
+ export function withoutHandoff(href: string): string {
78
+ const url = new URL(href);
79
+ if (!url.search) return url.toString();
80
+ // Pair by pair on the raw text: re-serializing would re-encode the other
81
+ // parameters, and the address would no longer be the one the page had.
82
+ const kept = url.search
83
+ .slice(1)
84
+ .split('&')
85
+ .filter((pair) => ![HANDOFF_PARAM, INTENT_PARAM].includes(decodeName(pair.split('=')[0])));
86
+ url.search = kept.length ? `?${kept.join('&')}` : '';
87
+ return url.toString();
88
+ }
89
+
90
+ /** A query parameter's name, decoded; as written when it can't be. */
91
+ function decodeName(raw: string): string {
92
+ try {
93
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
94
+ } catch {
95
+ return raw;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The address that hands someone to an action's page.
101
+ *
102
+ * @param pageUrl - The page the action's form is on.
103
+ * @param actionId - The action's `@id`.
104
+ * @param intent - What they want, in a few words. Nothing personal.
105
+ */
106
+ export function handoffUrl(pageUrl: string, actionId: string, intent?: string): string {
107
+ const url = new URL(pageUrl);
108
+ // Appended to the raw query, so the page's own parameters keep their encoding.
109
+ const added = new URLSearchParams({ [HANDOFF_PARAM]: actionId });
110
+ const said = intent ? cleanIntent(intent) : '';
111
+ if (said) added.set(INTENT_PARAM, said);
112
+ url.search = url.search ? `${url.search}&${added}` : `?${added}`;
113
+ return url.toString();
114
+ }
115
+
116
+ /**
117
+ * Read the handoff from this page's address and take it out.
118
+ *
119
+ * @param opts - The address and the history to rewrite; the browser's by default.
120
+ */
121
+ export function takeInboundHandoff(
122
+ opts: { href?: string; replace?: (href: string) => void } = {},
123
+ ): InboundHandoff | null {
124
+ const href = opts.href ?? (typeof location !== 'undefined' ? location.href : '');
125
+ const handoff = href ? readInboundHandoff(href) : null;
126
+ if (!handoff) return null;
127
+ const replace =
128
+ opts.replace ??
129
+ ((next: string) => {
130
+ try {
131
+ history.replaceState(history.state, '', next);
132
+ } catch {
133
+ // A sandboxed frame can refuse; the handoff is still taken once, below.
134
+ }
135
+ });
136
+ replace(withoutHandoff(href));
137
+ return handoff;
138
+ }
139
+
140
+ /** What the agent says to start a form: "Start the quote form". */
141
+ export function startMessage(tool: ModelContextToolInfo): string {
142
+ return startFormMessage(withoutSiteSuffix(tool.name).replace(/[_-]+/g, ' ').trim().toLowerCase());
143
+ }
144
+
145
+ /**
146
+ * What the agent says to start a site form for one business, in the words a
147
+ * result card's button uses: "Get a quote from McManus Electric".
148
+ */
149
+ export function startFormMessage(noun: string, choice?: string): string {
150
+ const named = noun || 'form';
151
+ const form = /\bform\b/.test(named) ? named : `${named} form`;
152
+ if (!choice) return `Start the ${form}`;
153
+ return /quote/.test(named) ? `Get a quote from ${choice}` : `Start the ${form} for ${choice}`;
154
+ }
155
+
156
+ /** Where the form a handoff names stands, once the page has loaded. */
157
+ export type ArrivalOutcome =
158
+ /** WebMCP never appeared, so the page's forms cannot be reached. */
159
+ | { status: 'no-registry' }
160
+ /** The page has no form for that action. */
161
+ | { status: 'no-tool' }
162
+ /** The form is here. */
163
+ | { status: 'ready'; tool: ModelContextToolInfo };
164
+
165
+ /** Overrides, so this can be exercised without a page. */
166
+ export interface ArrivalOptions {
167
+ /** How long to wait for the registry, and then for the tool. */
168
+ timeoutMs?: number;
169
+ /** Wait for a WebMCP registry. */
170
+ registry?(timeoutMs: number): Promise<ModelContext | null>;
171
+ /** Wait for the page's own tool for this action. */
172
+ pageTool?(mc: ModelContext, ref: { id: string; name: string }, timeoutMs: number): Promise<ModelContextToolInfo | null>;
173
+ }
174
+
175
+ /**
176
+ * Find the form a handoff names, waiting for the page to register it.
177
+ *
178
+ * Runs while the page is still loading, as the cross-page resume does: the
179
+ * registry and the form's tool usually appear a moment after the agent.
180
+ */
181
+ export async function findHandoffForm(handoff: InboundHandoff, opts: ArrivalOptions = {}): Promise<ArrivalOutcome> {
182
+ const timeoutMs = opts.timeoutMs ?? WAIT_MS;
183
+ const mc = await (opts.registry ?? ((ms: number) => waitForModelContext(ms)))(timeoutMs);
184
+ if (!mc) return { status: 'no-registry' };
185
+ // By id only: a name match could be a different form on this page.
186
+ const find = opts.pageTool ?? ((c, ref, ms) => awaitPageTool(c, ref, ms));
187
+ const tool = await find(mc, { id: handoff.actionId, name: '' }, timeoutMs);
188
+ return tool ? { status: 'ready', tool } : { status: 'no-tool' };
189
+ }
package/src/index.ts CHANGED
@@ -50,6 +50,9 @@ export type { Slot, Resolution, ResolveSources } from './harness/slots.js';
50
50
 
51
51
  export { buildPlan } from './harness/plan.js';
52
52
 
53
+ export { actionCta } from './harness/cta.js';
54
+ export type { CardAction } from './harness/cta.js';
55
+
53
56
  export { AgentCoordinator } from './harness/router/coordinator.js';
54
57
  export type { CoordinatorOptions, RoutedTurn, RouteSource } from './harness/router/coordinator.js';
55
58
  export { matchRoute } from './harness/router/rules.js';
@@ -134,6 +137,16 @@ export {
134
137
  export type { AgentStep, AgentStepId, AgentStepStatus } from './harness/steps.js';
135
138
  export { resumePendingAction } from './harness/tools/resume.js';
136
139
  export type { ResumeOutcome, ResumeOptions } from './harness/tools/resume.js';
140
+ // Arriving from the NxisAI app to use one of this page's forms.
141
+ export {
142
+ HANDOFF_PARAM,
143
+ INTENT_PARAM,
144
+ readInboundHandoff,
145
+ takeInboundHandoff,
146
+ handoffUrl,
147
+ findHandoffForm,
148
+ } from './harness/tools/inbound.js';
149
+ export type { InboundHandoff, ArrivalOutcome, ArrivalOptions } from './harness/tools/inbound.js';
137
150
  export {
138
151
  loadSitePages,
139
152
  parsePage,
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { mountSurface, type MountedSurface } from '../a2ui/host.js';
24
24
  import { emitCardsSurface, type ResultCardData } from '../a2ui/cards.js';
25
+ import { actionCta, type CardAction } from '../harness/cta.js';
25
26
  import { emitToolForm } from '../a2ui/form.js';
26
27
  import { A2UI_THEME_CSS } from '../a2ui/theme.js';
27
28
  // Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
@@ -46,6 +47,7 @@ import {
46
47
  formsFor,
47
48
  handoffFor,
48
49
  isSiteTool,
50
+ siteFormFor,
49
51
  withoutSiteSuffix,
50
52
  HANDOFF_RESULT,
51
53
  } from '../harness/tools/action-tools.js';
@@ -64,6 +66,7 @@ import { resultsAnswer, RESULTS_ANSWER_CSS, type AnswerCard } from './result-pic
64
66
  import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
65
67
  import type { ResultPick } from '../harness/recommend.js';
66
68
  import { resumePendingAction } from '../harness/tools/resume.js';
69
+ import { findHandoffForm, startFormMessage, startMessage, takeInboundHandoff } from '../harness/tools/inbound.js';
67
70
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
68
71
  import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
69
72
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
@@ -341,6 +344,8 @@ interface ResultCard {
341
344
  phone?: string;
342
345
  /** What the record states, short, for comparing it. */
343
346
  facts?: string[];
347
+ /** The action the record itself offers (its potentialAction), as a button. */
348
+ action?: CardAction;
344
349
  }
345
350
 
346
351
  type ThreadItem =
@@ -682,6 +687,8 @@ class NxisAgentFullscreen extends HTMLElement {
682
687
  // belongs to is the one open.
683
688
  void this._resumePendingAction();
684
689
  this._resumePendingVisit();
690
+ // Sent here from the NxisAI app to use one of this page's forms.
691
+ void this._arriveFromHandoff();
685
692
 
686
693
  this._els.launcher.addEventListener('click', () => this._open());
687
694
  this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
@@ -1589,7 +1596,14 @@ class NxisAgentFullscreen extends HTMLElement {
1589
1596
  }
1590
1597
 
1591
1598
  // ── Turn loop ──────────────────────────────────────────────────────────────
1592
- private async _onSend(): Promise<void> {
1599
+ /**
1600
+ * Send what is in the composer.
1601
+ *
1602
+ * @param call - A tool call already decided, run without asking the model
1603
+ * (a handoff names its form). Falls back to an ordinary turn when no agent
1604
+ * here holds the tool.
1605
+ */
1606
+ private async _onSend(call?: { name: string; args: Record<string, unknown> }): Promise<void> {
1593
1607
  const goal = this._els.input.value.trim();
1594
1608
  if (!goal || this._turnActive) return;
1595
1609
  this._els.input.value = '';
@@ -1626,7 +1640,9 @@ class NxisAgentFullscreen extends HTMLElement {
1626
1640
  await this._connectTools();
1627
1641
  this._syncProfileContext();
1628
1642
  // One agent, or several with a router in front: the signatures match.
1629
- await (this._coordinator ?? this._harness).turn(goal, conv!.messages);
1643
+ const engine = this._coordinator ?? this._harness;
1644
+ const begun = call ? await engine.begin(goal, call.name, call.args, conv!.messages) : null;
1645
+ if (!begun || begun.decision === 'unavailable') await engine.turn(goal, conv!.messages);
1630
1646
  // A remote endpoint reports which model answered; the name beside the
1631
1647
  // input follows it rather than keeping whatever was configured.
1632
1648
  this._paintProvenance();
@@ -2673,6 +2689,7 @@ class NxisAgentFullscreen extends HTMLElement {
2673
2689
  facts.since ? `In business since ${facts.since}` : '',
2674
2690
  facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
2675
2691
  ].filter((l): l is string => !!l);
2692
+ const action = actionCta(entity, title);
2676
2693
  return {
2677
2694
  title,
2678
2695
  type,
@@ -2686,6 +2703,7 @@ class NxisAgentFullscreen extends HTMLElement {
2686
2703
  ...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
2687
2704
  ...(status ? { status: { open: status.open, label: status.label } } : {}),
2688
2705
  ...(facts.phone ? { phone: facts.phone } : {}),
2706
+ ...(action ? { action } : {}),
2689
2707
  facts: lines,
2690
2708
  };
2691
2709
  }
@@ -2714,7 +2732,9 @@ class NxisAgentFullscreen extends HTMLElement {
2714
2732
  const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
2715
2733
  if (!item.pick && !things.length) return null;
2716
2734
  const cards: AnswerCard[] = item.cards.map((c) => {
2717
- const form = formsFor(c.title)[0];
2735
+ // The record's own action first: the same map and prompt as the Typed Retrieval skill and the NxisAI
2736
+ // app. A record that states none falls back to a form found on this page for it.
2737
+ const form = c.action ? undefined : formsFor(c.title)[0];
2718
2738
  const quote = form && /quote/.test(form.noun);
2719
2739
  return {
2720
2740
  title: c.title,
@@ -2724,7 +2744,9 @@ class NxisAgentFullscreen extends HTMLElement {
2724
2744
  phone: c.phone,
2725
2745
  page: c.page,
2726
2746
  facts: c.facts ?? [],
2727
- ...(form
2747
+ ...(c.action
2748
+ ? { form: { label: c.action.label, message: c.action.message } }
2749
+ : form
2728
2750
  ? {
2729
2751
  form: quote
2730
2752
  ? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
@@ -2762,6 +2784,7 @@ class NxisAgentFullscreen extends HTMLElement {
2762
2784
  ...(c.emergency ? [{ label: '24/7' }] : []),
2763
2785
  ],
2764
2786
  rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
2787
+ actions: c.action ? [c.action] : [],
2765
2788
  }));
2766
2789
  }
2767
2790
 
@@ -2775,7 +2798,15 @@ class NxisAgentFullscreen extends HTMLElement {
2775
2798
  }
2776
2799
  try {
2777
2800
  const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
2778
- const mounted = mountSurface(wrap, messages);
2801
+ const mounted = mountSurface(wrap, messages, (event: any) => {
2802
+ const name = event?.event?.name ?? event?.name ?? '';
2803
+ const ctx = (event?.event?.context ?? event?.context ?? {}) as Record<string, unknown>;
2804
+ // A card's action button: send its message as the visitor, as the inline card does.
2805
+ if (name === 'nxis.ask' && typeof ctx.message === 'string' && !this._turnActive) {
2806
+ this._els.input.value = ctx.message;
2807
+ void this._onSend();
2808
+ }
2809
+ });
2779
2810
  if (!mounted) throw new Error('no surface produced');
2780
2811
  this._surfaces.push(mounted);
2781
2812
  } catch (err) {
@@ -3503,6 +3534,42 @@ class NxisAgentFullscreen extends HTMLElement {
3503
3534
  if (this.mode !== 'fullscreen') this._open();
3504
3535
  }
3505
3536
 
3537
+ /**
3538
+ * Open on the form a link from the NxisAI app names.
3539
+ *
3540
+ * In a new conversation: the person arrives from another app, and nothing
3541
+ * on this site led here. The agent starts the form in its own words, so the
3542
+ * link only chooses which form; what the person wanted waits in the
3543
+ * composer for them to send, change or delete.
3544
+ */
3545
+ private async _arriveFromHandoff(): Promise<void> {
3546
+ const handoff = takeInboundHandoff();
3547
+ if (!handoff) return;
3548
+ this._newConversation();
3549
+ this._open();
3550
+ this._setStatus('Opening the form…');
3551
+ // The site's own tools first: they reach a form from any page, from the
3552
+ // site's records, so they work where the page registers nothing itself.
3553
+ await this._connectTools();
3554
+ const site = siteFormFor(handoff.actionId);
3555
+ const start = site
3556
+ ? startFormMessage(site.noun, site.choice)
3557
+ : await findHandoffForm(handoff).then((o) => (o.status === 'ready' ? startMessage(o.tool) : null));
3558
+ this._setStatus('');
3559
+ if (!start) {
3560
+ this._appendMsg('assistant', "I couldn't find that form on this page. Tell me what you need and I'll help from here.");
3561
+ } else if (!this._turnActive) {
3562
+ this._els.input.value = start;
3563
+ // The form is known, so it opens without the model choosing it: left to
3564
+ // choose, a model sometimes asks for the details in prose instead.
3565
+ await this._onSend(site ? { name: site.tool, args: site.args } : undefined);
3566
+ }
3567
+ if (handoff.intent && !this._els.input.value) {
3568
+ this._els.input.value = handoff.intent;
3569
+ this._autosize();
3570
+ }
3571
+ }
3572
+
3506
3573
  private async _resumePendingAction(): Promise<void> {
3507
3574
  const outcome = await resumePendingAction({
3508
3575
  onStart: (pending) => {