textweb 0.2.0 → 0.2.4

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/README.md CHANGED
@@ -92,6 +92,12 @@ npx textweb-mcp
92
92
 
93
93
  Then just ask: *"Go to hacker news and find posts about AI"* — the agent uses text grids instead of screenshots.
94
94
 
95
+ **New (v0.2.1-style MCP capabilities):**
96
+ - `session_id` on every tool call for isolated parallel workflows
97
+ - `textweb_storage_save` / `textweb_storage_load` for persistent auth/session state
98
+ - `textweb_wait_for` for multi-step async UI transitions
99
+ - `textweb_assert_field` for flow guards before submit
100
+
95
101
  ### 🛠️ OpenAI / Anthropic Function Calling
96
102
 
97
103
  Drop-in tool definitions for any function-calling model. See [`tools/tool_definitions.json`](tools/tool_definitions.json).
@@ -174,10 +180,18 @@ const { view, elements, meta } = await browser.navigate('https://example.com');
174
180
 
175
181
  console.log(view); // The text grid
176
182
  console.log(elements); // { 0: { selector, tag, text, href }, ... }
183
+ console.log(meta.stats); // { totalElements, interactiveElements, renderMs }
177
184
 
178
185
  await browser.click(3); // Click element [3]
179
186
  await browser.type(7, 'hello'); // Type into element [7]
180
187
  await browser.scroll('down'); // Scroll down
188
+ await browser.waitFor({ selector: '.step-2.active' }); // Wait for next step
189
+ await browser.assertField(7, 'hello', { comparator: 'equals' }); // Validate field state
190
+ await browser.saveStorageState('/tmp/textweb-state.json');
191
+ await browser.loadStorageState('/tmp/textweb-state.json');
192
+ await browser.query('nav a'); // Find elements by CSS selector
193
+ await browser.screenshot(); // PNG buffer (for debugging)
194
+ console.log(browser.getCurrentUrl());// Current page URL
181
195
  await browser.close();
182
196
  ```
183
197
 
@@ -218,6 +232,67 @@ await browser.close();
218
232
  3. **Map** pixel coordinates to character grid positions (spatial layout preserved)
219
233
  4. **Annotate** interactive elements with `[ref]` numbers for agent interaction
220
234
 
235
+ ## Selector Strategy
236
+
237
+ TextWeb builds stable CSS selectors for each interactive element, preferring resilient strategies over brittle positional ones:
238
+
239
+ | Priority | Strategy | Example |
240
+ |----------|----------|---------|
241
+ | 1 | `#id` | `#email` |
242
+ | 2 | `[data-testid]` | `[data-testid="submit-btn"]` |
243
+ | 3 | `[aria-label]` | `input[aria-label="Search"]` |
244
+ | 4 | `[role]` (if unique) | `[role="navigation"]` |
245
+ | 5 | `[name]` | `input[name="email"]` |
246
+ | 6 | `a[href]` (if unique) | `a[href="/about"]` |
247
+ | 7 | `nth-child` (fallback) | `div > a:nth-child(3)` |
248
+
249
+ This means selectors survive DOM changes between snapshots — critical for multi-step agent workflows.
250
+
251
+ ## ATS Workflow Examples (Greenhouse / Lever)
252
+
253
+ For multi-step ATS flows, use a stable `session_id` and combine wait/assert guards:
254
+
255
+ ```javascript
256
+ // Keep one session for the whole application
257
+ await textweb_navigate({ url: 'https://job-boards.greenhouse.io/acme/jobs/123', session_id: 'apply-acme' });
258
+
259
+ // Fill + continue
260
+ await textweb_type({ ref: 12, text: 'Christopher', session_id: 'apply-acme' });
261
+ await textweb_type({ ref: 15, text: 'Robison', session_id: 'apply-acme' });
262
+ await textweb_click({ ref: 42, session_id: 'apply-acme', retries: 3, retry_delay_ms: 400 });
263
+
264
+ // Guard transition
265
+ await textweb_wait_for({ selector: '#step-2.active', timeout_ms: 8000, session_id: 'apply-acme', retries: 2 });
266
+
267
+ // Validate before submit
268
+ await textweb_assert_field({ ref: 77, expected: 'San Francisco', comparator: 'includes', session_id: 'apply-acme' });
269
+
270
+ // Persist auth/session for follow-up flow
271
+ await textweb_storage_save({ path: '/tmp/ats-state.json', session_id: 'apply-acme' });
272
+ ```
273
+
274
+ Useful session tools:
275
+ - `textweb_session_list` → inspect active sessions
276
+ - `textweb_session_close` → close one session or all
277
+
278
+ ## Testing
279
+
280
+ ```bash
281
+ # Run all tests (form + live + ATS e2e)
282
+ npm test
283
+
284
+ # Form fixture tests
285
+ npm run test:form
286
+
287
+ # Live site tests — example.com, HN, Wikipedia
288
+ npm run test:live
289
+
290
+ # ATS multi-step fixture test
291
+ npm run test:ats
292
+ ```
293
+
294
+ Test fixtures are in `test/fixtures/` — includes a comprehensive HTML form and an ATS-style multi-step application fixture.
295
+
221
296
  ## Design Principles
222
297
 
223
298
  1. **Text is native to LLMs** — no vision model middleman
package/logo.svg ADDED
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 425.697 360.796" xmlns:bx="https://boxy-svg.com">
3
+ <defs>
4
+ <style>
5
+ @keyframes blink {
6
+ 0%, 100% { opacity: 1; }
7
+ 50% { opacity: 0; }
8
+ }
9
+ .cursor {
10
+ animation: blink 1.1s step-start infinite;
11
+ }
12
+ </style>
13
+ <filter id="ds" x="-20%" y="-20%" width="140%" height="140%">
14
+ <feDropShadow dx="0" dy="10" stdDeviation="12" flood-color="#000" flood-opacity="0.35"/>
15
+ </filter>
16
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
17
+ <stop offset="0" stop-color="var(--bg)"/>
18
+ <stop offset="1" stop-color="var(--bg2)"/>
19
+ </linearGradient>
20
+ <bx:export>
21
+ <bx:file format="png"/>
22
+ </bx:export>
23
+ </defs>
24
+ <rect x="6.482" y="11.817" width="400" height="320" rx="72" ry="72" fill="url(#g)" filter="url(#ds)" style="stroke-width: 1;"/>
25
+ <rect x="26.482" y="31.817" width="360" height="280" rx="58" ry="58" fill="none" stroke-width="4" style="stroke-width: 4; stroke: rgba(255, 255, 255, 0.255);"/>
26
+ <g class="mono" fill="var(--green)" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, &quot;Liberation Mono&quot;, monospace;" transform="matrix(1, 0, 0, 1, -75.701332, -120.82827)">
27
+ <text style="fill: rgb(0, 201, 22); font-size: 56px; font-weight: 700; letter-spacing: 0.5px; white-space: pre;" x="112.671" y="313.449">&gt; textweb<tspan class="cursor">_</tspan><tspan x="112.6709976196289" dy="1em">​</tspan></text>
28
+ </g>
29
+ <circle cx="90.479" cy="155.817" r="70" fill="var(--green)" opacity="0.05" style="stroke-width: 1;"/>
30
+ </svg>
package/mcp/index.js CHANGED
@@ -2,21 +2,24 @@
2
2
 
3
3
  /**
4
4
  * TextWeb MCP Server
5
- *
5
+ *
6
6
  * Model Context Protocol server that gives any MCP client
7
7
  * (Claude Desktop, Cursor, Windsurf, Cline, OpenClaw, etc.)
8
8
  * text-based web browsing capabilities.
9
- *
9
+ *
10
10
  * Communicates over stdio using JSON-RPC 2.0.
11
11
  */
12
12
 
13
13
  const { AgentBrowser } = require('../src/browser');
14
+ const { ensureBrowser } = require('../src/ensure-browser');
14
15
 
15
16
  const SERVER_INFO = {
16
17
  name: 'textweb',
17
- version: '0.1.0',
18
+ version: '0.2.2',
18
19
  };
19
20
 
21
+ const SESSION_NOTE = 'Optional session_id to isolate state across flows. Defaults to "default".';
22
+
20
23
  const TOOLS = [
21
24
  {
22
25
  name: 'textweb_navigate',
@@ -26,6 +29,9 @@ const TOOLS = [
26
29
  properties: {
27
30
  url: { type: 'string', description: 'The URL to navigate to' },
28
31
  cols: { type: 'number', description: 'Grid width in characters (default: 120)' },
32
+ session_id: { type: 'string', description: SESSION_NOTE },
33
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
34
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
29
35
  },
30
36
  required: ['url'],
31
37
  },
@@ -37,6 +43,9 @@ const TOOLS = [
37
43
  type: 'object',
38
44
  properties: {
39
45
  ref: { type: 'number', description: 'Element reference number from the text grid (e.g., 3 for [3])' },
46
+ session_id: { type: 'string', description: SESSION_NOTE },
47
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
48
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
40
49
  },
41
50
  required: ['ref'],
42
51
  },
@@ -49,6 +58,9 @@ const TOOLS = [
49
58
  properties: {
50
59
  ref: { type: 'number', description: 'Element reference number of the input field' },
51
60
  text: { type: 'string', description: 'Text to type into the field' },
61
+ session_id: { type: 'string', description: SESSION_NOTE },
62
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
63
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
52
64
  },
53
65
  required: ['ref', 'text'],
54
66
  },
@@ -61,6 +73,9 @@ const TOOLS = [
61
73
  properties: {
62
74
  ref: { type: 'number', description: 'Element reference number of the select/dropdown' },
63
75
  value: { type: 'string', description: 'Value or visible text of the option to select' },
76
+ session_id: { type: 'string', description: SESSION_NOTE },
77
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
78
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
64
79
  },
65
80
  required: ['ref', 'value'],
66
81
  },
@@ -73,6 +88,7 @@ const TOOLS = [
73
88
  properties: {
74
89
  direction: { type: 'string', enum: ['up', 'down', 'top'], description: 'Scroll direction' },
75
90
  amount: { type: 'number', description: 'Number of pages to scroll (default: 1)' },
91
+ session_id: { type: 'string', description: SESSION_NOTE },
76
92
  },
77
93
  required: ['direction'],
78
94
  },
@@ -82,7 +98,9 @@ const TOOLS = [
82
98
  description: 'Re-render the current page as a text grid without navigating. Useful after waiting for dynamic content to load.',
83
99
  inputSchema: {
84
100
  type: 'object',
85
- properties: {},
101
+ properties: {
102
+ session_id: { type: 'string', description: SESSION_NOTE },
103
+ },
86
104
  },
87
105
  },
88
106
  {
@@ -92,10 +110,32 @@ const TOOLS = [
92
110
  type: 'object',
93
111
  properties: {
94
112
  key: { type: 'string', description: 'Key to press (e.g., "Enter", "Tab", "Escape", "ArrowDown")' },
113
+ session_id: { type: 'string', description: SESSION_NOTE },
114
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
115
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
95
116
  },
96
117
  required: ['key'],
97
118
  },
98
119
  },
120
+ {
121
+ name: 'textweb_session_list',
122
+ description: 'List active textweb sessions and basic metadata (url, age).',
123
+ inputSchema: {
124
+ type: 'object',
125
+ properties: {},
126
+ },
127
+ },
128
+ {
129
+ name: 'textweb_session_close',
130
+ description: 'Close one session by session_id, or all sessions when all=true.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: {
134
+ session_id: { type: 'string', description: 'Session id to close (default: default)' },
135
+ all: { type: 'boolean', description: 'Close all active sessions' },
136
+ },
137
+ },
138
+ },
99
139
  {
100
140
  name: 'textweb_upload',
101
141
  description: 'Upload a file to a file input element by its reference number.',
@@ -104,22 +144,93 @@ const TOOLS = [
104
144
  properties: {
105
145
  ref: { type: 'number', description: 'Element reference number of the file input' },
106
146
  path: { type: 'string', description: 'Absolute path to the file to upload' },
147
+ session_id: { type: 'string', description: SESSION_NOTE },
148
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
149
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
107
150
  },
108
151
  required: ['ref', 'path'],
109
152
  },
110
153
  },
154
+ {
155
+ name: 'textweb_storage_save',
156
+ description: 'Save current browser storage state (cookies/localStorage/sessionStorage) to disk for later restore.',
157
+ inputSchema: {
158
+ type: 'object',
159
+ properties: {
160
+ path: { type: 'string', description: 'Absolute path to write storage state JSON' },
161
+ session_id: { type: 'string', description: SESSION_NOTE },
162
+ },
163
+ required: ['path'],
164
+ },
165
+ },
166
+ {
167
+ name: 'textweb_storage_load',
168
+ description: 'Load storage state from disk into a fresh browser context.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: {
172
+ path: { type: 'string', description: 'Absolute path of previously saved storage state JSON' },
173
+ cols: { type: 'number', description: 'Grid width in characters (default: 120)' },
174
+ session_id: { type: 'string', description: SESSION_NOTE },
175
+ },
176
+ required: ['path'],
177
+ },
178
+ },
179
+ {
180
+ name: 'textweb_wait_for',
181
+ description: 'Wait for UI state in multi-step flows. Supports selector, text, and url_includes checks.',
182
+ inputSchema: {
183
+ type: 'object',
184
+ properties: {
185
+ selector: { type: 'string', description: 'CSS selector that must appear (or match state)' },
186
+ text: { type: 'string', description: 'Text that must appear in page body' },
187
+ url_includes: { type: 'string', description: 'Substring that must appear in current URL' },
188
+ state: { type: 'string', enum: ['attached', 'detached', 'visible', 'hidden'], description: 'Selector wait state (default: visible)' },
189
+ timeout_ms: { type: 'number', description: 'Timeout in milliseconds (default: 30000)' },
190
+ poll_ms: { type: 'number', description: 'Polling interval for text/url waits (default: 100)' },
191
+ retries: { type: 'number', description: 'Retry attempts for flaky transitions' },
192
+ retry_delay_ms: { type: 'number', description: 'Delay between retries in ms' },
193
+ session_id: { type: 'string', description: SESSION_NOTE },
194
+ },
195
+ },
196
+ },
197
+ {
198
+ name: 'textweb_assert_field',
199
+ description: 'Assert a field value/text by element ref. Useful in multi-step forms before submitting.',
200
+ inputSchema: {
201
+ type: 'object',
202
+ properties: {
203
+ ref: { type: 'number', description: 'Element reference number from current snapshot' },
204
+ expected: { type: 'string', description: 'Expected value/content' },
205
+ comparator: { type: 'string', enum: ['equals', 'includes', 'regex', 'not_empty'], description: 'Comparison mode (default: equals)' },
206
+ attribute: { type: 'string', description: 'Optional DOM attribute name to validate (e.g., aria-invalid)' },
207
+ session_id: { type: 'string', description: SESSION_NOTE },
208
+ },
209
+ required: ['ref', 'expected'],
210
+ },
211
+ },
111
212
  ];
112
213
 
113
- // ─── Browser Instance ────────────────────────────────────────────────────────
214
+ // ─── Browser Sessions ───────────────────────────────────────────────────────
215
+
216
+ /** @type {Map<string, AgentBrowser>} */
217
+ const sessions = new Map();
114
218
 
115
- let browser = null;
219
+ function resolveSessionId(args = {}) {
220
+ return (args.session_id || 'default').trim() || 'default';
221
+ }
222
+
223
+ async function getBrowser(args = {}) {
224
+ const sessionId = resolveSessionId(args);
225
+ let browser = sessions.get(sessionId);
116
226
 
117
- async function getBrowser(cols) {
118
227
  if (!browser) {
119
- browser = new AgentBrowser({ cols: cols || 120, headless: true });
228
+ browser = new AgentBrowser({ cols: args.cols || 120, headless: true });
120
229
  await browser.launch();
230
+ sessions.set(sessionId, browser);
121
231
  }
122
- return browser;
232
+
233
+ return { browser, sessionId };
123
234
  }
124
235
 
125
236
  function formatResult(result) {
@@ -130,26 +241,78 @@ function formatResult(result) {
130
241
  return `URL: ${result.meta?.url || 'unknown'}\nTitle: ${result.meta?.title || 'unknown'}\nRefs: ${result.meta?.totalRefs || 0}\n\n${result.view}\n\nInteractive elements:\n${refs}`;
131
242
  }
132
243
 
244
+ function retryOptions(args = {}) {
245
+ return {
246
+ retries: args.retries,
247
+ retryDelayMs: args.retry_delay_ms,
248
+ };
249
+ }
250
+
251
+ async function listSessions() {
252
+ const out = [];
253
+ for (const [sessionId, browser] of sessions.entries()) {
254
+ out.push({
255
+ session_id: sessionId,
256
+ url: browser.getCurrentUrl() || null,
257
+ initialized: Boolean(browser.page),
258
+ refs: browser.lastResult?.meta?.totalRefs ?? null,
259
+ });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ async function closeSession({ session_id, all } = {}) {
265
+ if (all) {
266
+ const closed = [];
267
+ for (const [sid, browser] of sessions.entries()) {
268
+ await browser.close();
269
+ closed.push(sid);
270
+ }
271
+ sessions.clear();
272
+ return { closed };
273
+ }
274
+
275
+ const sid = (session_id || 'default').trim() || 'default';
276
+ const browser = sessions.get(sid);
277
+ if (!browser) {
278
+ return { closed: [], missing: [sid] };
279
+ }
280
+
281
+ await browser.close();
282
+ sessions.delete(sid);
283
+ return { closed: [sid] };
284
+ }
285
+
133
286
  // ─── Tool Execution ──────────────────────────────────────────────────────────
134
287
 
135
- async function executeTool(name, args) {
136
- const b = await getBrowser(args.cols);
288
+ async function executeTool(name, args = {}) {
289
+ if (name === 'textweb_session_list') {
290
+ const active = await listSessions();
291
+ return JSON.stringify({ count: active.length, sessions: active }, null, 2);
292
+ }
293
+
294
+ if (name === 'textweb_session_close') {
295
+ const out = await closeSession({ session_id: args.session_id, all: args.all });
296
+ return JSON.stringify(out, null, 2);
297
+ }
298
+
299
+ const { browser: b, sessionId } = await getBrowser(args);
137
300
 
138
301
  switch (name) {
139
302
  case 'textweb_navigate': {
140
- const result = await b.navigate(args.url);
303
+ const result = await b.navigate(args.url, retryOptions(args));
141
304
  return formatResult(result);
142
305
  }
143
306
  case 'textweb_click': {
144
- const result = await b.click(args.ref);
307
+ const result = await b.click(args.ref, retryOptions(args));
145
308
  return formatResult(result);
146
309
  }
147
310
  case 'textweb_type': {
148
- const result = await b.type(args.ref, args.text);
311
+ const result = await b.type(args.ref, args.text, retryOptions(args));
149
312
  return formatResult(result);
150
313
  }
151
314
  case 'textweb_select': {
152
- const result = await b.select(args.ref, args.value);
315
+ const result = await b.select(args.ref, args.value, retryOptions(args));
153
316
  return formatResult(result);
154
317
  }
155
318
  case 'textweb_scroll': {
@@ -161,13 +324,40 @@ async function executeTool(name, args) {
161
324
  return formatResult(result);
162
325
  }
163
326
  case 'textweb_press': {
164
- const result = await b.press(args.key);
327
+ const result = await b.press(args.key, retryOptions(args));
165
328
  return formatResult(result);
166
329
  }
167
330
  case 'textweb_upload': {
168
- const result = await b.upload(args.ref, args.path);
331
+ const result = await b.upload(args.ref, args.path, retryOptions(args));
332
+ return formatResult(result);
333
+ }
334
+ case 'textweb_storage_save': {
335
+ const out = await b.saveStorageState(args.path);
336
+ return `Saved storage state for session "${sessionId}" to ${out.path}`;
337
+ }
338
+ case 'textweb_storage_load': {
339
+ const out = await b.loadStorageState(args.path);
340
+ return `Loaded storage state for session "${sessionId}" from ${out.path}`;
341
+ }
342
+ case 'textweb_wait_for': {
343
+ const result = await b.waitFor({
344
+ selector: args.selector,
345
+ text: args.text,
346
+ urlIncludes: args.url_includes,
347
+ timeoutMs: args.timeout_ms,
348
+ pollMs: args.poll_ms,
349
+ state: args.state,
350
+ ...retryOptions(args),
351
+ });
169
352
  return formatResult(result);
170
353
  }
354
+ case 'textweb_assert_field': {
355
+ const out = await b.assertField(args.ref, args.expected, {
356
+ comparator: args.comparator,
357
+ attribute: args.attribute,
358
+ });
359
+ return `ASSERT ${out.pass ? 'PASS' : 'FAIL'} | ref=${out.ref} | comparator=${out.comparator} | expected="${out.expected}" | actual="${out.actual}" | selector=${out.selector}`;
360
+ }
171
361
  default:
172
362
  throw new Error(`Unknown tool: ${name}`);
173
363
  }
@@ -232,7 +422,7 @@ function main() {
232
422
  process.stdin.setEncoding('utf8');
233
423
  process.stdin.on('data', async (chunk) => {
234
424
  buffer += chunk;
235
-
425
+
236
426
  // Process complete lines (newline-delimited JSON)
237
427
  const lines = buffer.split('\n');
238
428
  buffer = lines.pop(); // Keep incomplete line in buffer
@@ -257,19 +447,23 @@ function main() {
257
447
  });
258
448
 
259
449
  process.stdin.on('end', async () => {
260
- if (browser) await browser.close();
450
+ for (const [, browser] of sessions) {
451
+ await browser.close();
452
+ }
453
+ sessions.clear();
261
454
  process.exit(0);
262
455
  });
263
456
 
264
457
  process.on('SIGINT', async () => {
265
- if (browser) await browser.close();
266
- process.exit(0);
267
- });
268
-
269
- process.on('SIGTERM', async () => {
270
- if (browser) await browser.close();
458
+ for (const [, browser] of sessions) {
459
+ await browser.close();
460
+ }
461
+ sessions.clear();
271
462
  process.exit(0);
272
463
  });
273
464
  }
274
465
 
275
- main();
466
+ ensureBrowser().then(main).catch((err) => {
467
+ process.stderr.write(`Fatal: ${err.message}\n`);
468
+ process.exit(1);
469
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "textweb",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "A text-grid web renderer for AI agents — see the web without screenshots",
5
5
  "main": "src/browser.js",
6
6
  "bin": {
@@ -10,8 +10,19 @@
10
10
  "scripts": {
11
11
  "start": "node src/cli.js",
12
12
  "serve": "node src/server.js",
13
- "test": "node test/basic.js"
13
+ "test": "node test/test-form.js && node test/test-live.js && node test/test-ats-e2e.js",
14
+ "test:form": "node test/test-form.js",
15
+ "test:live": "node test/test-live.js",
16
+ "test:ats": "node test/test-ats-e2e.js"
14
17
  },
18
+ "files": [
19
+ "src/",
20
+ "mcp/",
21
+ "tools/",
22
+ "logo.svg",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
15
26
  "keywords": [
16
27
  "ai",
17
28
  "agent",