@stage-labs/metro 0.1.0-beta.247 → 0.1.0-beta.249

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.247",
3
+ "version": "0.1.0-beta.249",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.247",
3
+ "version": "0.1.0-beta.249",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: stage
3
- description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views). Works only when this agent has an XMTP account in metro.
3
+ description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views) and wallet cards (payment and signature requests, receipts). Works only when this agent has an XMTP account in metro.
4
4
  ---
5
5
 
6
6
  Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XMTP group. You use it with the metro tools below (`mcp__metro__<name>`).
@@ -18,9 +18,10 @@ Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XM
18
18
  - Direct chat: `dm {"address": "0x…"}` opens a 1:1 chat and returns its line.
19
19
  - With several XMTP accounts, add `"account": "<id>"` to `create_group` and `dm`.
20
20
 
21
- **Labels and assignees.** They live in the channel's metadata, and Stage shows them on the channel.
21
+ **Labels, assignees and category.** They live in the channel's metadata, and Stage shows them on the channel.
22
22
  - Labels: `set_channel_metadata {"line": "…", "metadata": {"labels": ["🚧 In progress", "Metro"]}}`. At most 16 labels of 24 characters each.
23
23
  - Assignees: `set_channel_metadata {"line": "…", "metadata": {"assigned": ["0x…"]}}`. Only 0x addresses of current members.
24
+ - Category: `set_channel_metadata {"line": "…", "metadata": {"category": "Clients"}}`. One per channel, 24 characters at most; `""` clears it. `group_info` returns it as `category`.
24
25
  - Each key you send replaces that whole list. Keys you leave out stay as they are. To add one label or one assignee, read `group_info` first and send the old list plus the new one. `[]` clears a list. Read `group_info` again to check it saved.
25
26
 
26
27
  **Task status labels.** When a channel is a task, keep exactly one of these status labels, plus any project labels:
@@ -73,3 +74,17 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
73
74
  ]}}
74
75
  }}}
75
76
  ```
77
+
78
+ **Wallet cards.** The payment and signature cards Stage's own Request payment and Request signature send. Sending one moves nothing and signs nothing: the person taps Pay or Sign and confirms in Stage. Send one only when you were asked to.
79
+ - Send: `send {"line": "…", "wallet": {"type": "<type>", "content": {…}}}`. With `text` or a `frame` as well, the card goes last, and the `message_id` you get back is the card's.
80
+ - Payment request, `walletSendCalls`, a card with a Pay button. Stage pays a request with one call; a Stage wallet pays on Base only.
81
+ ```json
82
+ {"type": "walletSendCalls", "content": {"version": "1.0", "chainId": "0x2105", "from": "0x<your address>", "calls": [
83
+ {"to": "0x<who gets paid>", "value": "0x38d7ea4c68000", "metadata": {"description": "Dinner", "transactionType": "transfer",
84
+ "currency": "ETH", "amount": 0.001, "decimals": 18, "toAddress": "0x<who gets paid>"}}
85
+ ]}}
86
+ ```
87
+ `chainId` is a hex string (`0x2105` is Base) and `value` is in wei, hex. For a token, `to` is the token contract, `data` its `transfer(address,uint256)` call data, `value` is left out, and `metadata` has `currency`, `amount` and `toAddress` (who gets paid).
88
+ - Receipt, `transactionReference`: `{"networkId": 8453, "reference": "0x<tx hash>", "metadata": {"currency": "USDC", "amount": 5}}`. Stage shows "Payment sent · 5 USDC" (or "Transaction sent" without an amount) and a link to the explorer. Only post a real transaction hash.
89
+ - Signature request, `signatureRequest`, a card with a Sign button: `{"id": "<any id>", "kind": "personal", "message": "The text to sign", "description": "Why"}`, or `"kind": "eip712"` with `"eip712": {"domain", "types", "primaryType", "message"}` instead of `message`.
90
+ - Signature, `signatureReference`: `{"requestId": "<message_id of the request>", "signature": "0x…", "signer": "0x…"}`. Stage shows it as Signed.
@@ -166,6 +166,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
166
166
  'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
167
167
  '08a30f5cd5cf8013ab70c1fa9e1109018c0337552296a8efe5c0cf543b284991',
168
168
  'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
169
+ 'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
169
170
  ]);
170
171
  const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
171
172
 
@@ -60,16 +60,20 @@ function assertDelivered(
60
60
  );
61
61
  }
62
62
 
63
- const frameOf = (m: MessageArgs): Record<string, unknown> =>
64
- m.a.frame === undefined ? {} : { frame: m.a.frame };
63
+ const STAGE_PARTS = ['frame', 'wallet'] as const;
64
+
65
+ const stagePartsIn = (m: MessageArgs): string[] => STAGE_PARTS.filter((key) => m.a[key] !== undefined);
66
+
67
+ const stagePartArgs = (m: MessageArgs): Record<string, unknown> =>
68
+ Object.fromEntries(stagePartsIn(m).map((key) => [key, m.a[key]]));
65
69
 
66
70
  const sentLabels = (m: MessageArgs, text: string | undefined): string[] => [
67
71
  ...(text ? ['text'] : []),
68
- ...(m.a.frame === undefined ? [] : ['frame']),
72
+ ...stagePartsIn(m),
69
73
  ];
70
74
 
71
75
  function nativeBody(m: MessageArgs, text: string | undefined, replyTo: string | undefined): Record<string, unknown> {
72
- const body: Record<string, unknown> = { line: m.line, ...frameOf(m) };
76
+ const body: Record<string, unknown> = { line: m.line, ...stagePartArgs(m) };
73
77
  if (text) body.text = text;
74
78
  if (text && replyTo) body.replyTo = replyTo;
75
79
  return body;
@@ -101,7 +105,7 @@ async function sendForwarded(
101
105
  atts: ResolvedAttachment[],
102
106
  ): Promise<Sent> {
103
107
  const { line, ctx, station } = m;
104
- const args: Record<string, unknown> = { line, ...frameOf(m) };
108
+ const args: Record<string, unknown> = { line, ...stagePartArgs(m) };
105
109
  if (text) args.text = text;
106
110
  if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
107
111
  if (replyTo) args.replyTo = replyTo;
@@ -123,12 +127,13 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
123
127
  .map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
124
128
  .join(', ')}); send a link in \`text\` instead`;
125
129
 
126
- const NOTHING_TO_SEND = 'send requires `text`, `attachments` or `frame`';
130
+ const NOTHING_TO_SEND = 'send requires `text`, `attachments`, `frame` or `wallet`';
127
131
 
128
132
  function refusal(m: MessageArgs, text: string | undefined, requested: CanonicalAttachment[]): string | undefined {
129
- if (!text && !requested.length && m.a.frame === undefined) return NOTHING_TO_SEND;
130
- if (m.a.frame !== undefined && m.station.sendsFrames !== true)
131
- return `${m.station.name} cannot send a frame; frames are Stage (XMTP) only`;
133
+ const stageOnly = stagePartsIn(m);
134
+ if (!text && !requested.length && !stageOnly.length) return NOTHING_TO_SEND;
135
+ if (stageOnly.length && m.station.sendsFrames !== true)
136
+ return `${m.station.name} cannot send \`${stageOnly.join('` or `')}\`; that is Stage (XMTP) only`;
132
137
  if (requested.length && m.station.attachmentMode === 'none') return unsupported(m.station, requested);
133
138
  return undefined;
134
139
  }
@@ -45,7 +45,7 @@ function argText(value: unknown): string | undefined {
45
45
 
46
46
  function fieldText(key: string, value: unknown): string | undefined {
47
47
  if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
48
- return key === 'frame' ? argText(value) : valueText(value);
48
+ return key === 'frame' || key === 'wallet' ? argText(value) : valueText(value);
49
49
  }
50
50
 
51
51
  function metroLines(tool: string, input: Record<string, unknown>): string[] {
@@ -34,7 +34,7 @@ export const COMMON_TOOLS: ToolDef[] = [
34
34
  group: 'write',
35
35
  description:
36
36
  'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
37
- 'subject?, attachments?, frame?. The station is derived from the line. Outlook and Gmail: send to ' +
37
+ 'subject?, attachments?, frame?, wallet?. The station is derived from the line. Outlook and Gmail: send to ' +
38
38
  'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
39
39
  'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
40
40
  'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
@@ -42,8 +42,8 @@ export const COMMON_TOOLS: ToolDef[] = [
42
42
  '`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
43
43
  'written out verbatim in the call and longer than that it corrupts); `url` (the daemon ' +
44
44
  'fetches it, so the file must already be public); `path` (resolved on the DAEMON host, ' +
45
- 'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view. At least one ' +
46
- 'of text/attachments/frame is required. ' +
45
+ 'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view, `wallet` a payment ' +
46
+ 'or signature card. At least one of text/attachments/frame/wallet is required. ' +
47
47
  'The success line names each attachment the station actually delivered; a station that ' +
48
48
  'cannot carry a file errors instead of reporting success.',
49
49
  inputSchema: {
@@ -99,6 +99,23 @@ export const COMMON_TOOLS: ToolDef[] = [
99
99
  description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
100
100
  },
101
101
  },
102
+ wallet: {
103
+ type: 'object',
104
+ description:
105
+ 'Stage (XMTP) only: a wallet card. `type` walletSendCalls is a payment request with a Pay button, ' +
106
+ 'transactionReference a payment receipt with an explorer link, signatureRequest a Sign button, ' +
107
+ 'signatureReference a signed proof. `content` is that type\'s JSON as Stage reads it; the stage skill ' +
108
+ 'has an example of each. Sending it moves nothing: the person taps Pay or Sign and confirms in Stage. ' +
109
+ "It is sent after the text and any frame, and the message_id returned is the card's.",
110
+ properties: {
111
+ type: {
112
+ type: 'string',
113
+ enum: ['walletSendCalls', 'transactionReference', 'signatureRequest', 'signatureReference'],
114
+ },
115
+ content: { type: 'object', description: 'The JSON of that content type.', additionalProperties: true },
116
+ },
117
+ required: ['type', 'content'],
118
+ },
102
119
  },
103
120
  required: ['line'],
104
121
  },
@@ -105,12 +105,14 @@ async function applyChannelMeta(
105
105
 
106
106
  function metaFields(merged: Record<string, unknown>): {
107
107
  labels: string[];
108
+ category: string | undefined;
108
109
  github: string | undefined;
109
110
  preview: string | undefined;
110
111
  assigned: unknown;
111
112
  } {
112
113
  return {
113
114
  labels: Array.isArray(merged.labels) ? (merged.labels as string[]) : [],
115
+ category: typeof merged.category === 'string' ? merged.category : undefined,
114
116
  github: typeof merged.github === 'string' ? merged.github : undefined,
115
117
  preview: typeof merged.preview === 'string' ? merged.preview : undefined,
116
118
  assigned: Object.hasOwn(merged, 'assigned') ? merged.assigned : [],
@@ -9,11 +9,13 @@ import {
9
9
  AttachmentCodec,
10
10
  type Attachment,
11
11
  } from '@xmtp/content-type-remote-attachment';
12
+ import type { EncodedContent } from '@xmtp/content-type-primitives';
12
13
  import { convOf, type Account } from './accounts.js';
13
14
  import { resolveMsgId } from './wire.js';
14
15
  import { emitOutbound } from './emit.js';
15
16
  import { CallSignalCodec, FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
16
17
  import { buildFrameContent } from './frames.js';
18
+ import { buildWalletContent } from './wallet.js';
17
19
  import { callLeftovers } from './call-leftovers.js';
18
20
  import { convHandlers } from './actions-conv.js';
19
21
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
@@ -30,19 +32,27 @@ const noConv = (line: string): TrainError =>
30
32
  const badArgs = (message: string): TrainError =>
31
33
  new TrainError('INVALID_ARGS', message);
32
34
 
35
+ function framePart(raw: unknown): { encoded: EncodedContent; summary: string } {
36
+ const built = buildFrameContent(raw);
37
+ return { encoded: new FrameCodec().encode(built.frame), summary: `Frame: ${built.title}` };
38
+ }
39
+
33
40
  async function send(id: string, args: Args): Promise<void> {
34
41
  const { line, text } = args as { line: string; text: string };
35
- const built = args.frame === undefined ? undefined : buildFrameContent(args.frame);
42
+ const parts = [
43
+ ...(args.frame === undefined ? [] : [framePart(args.frame)]),
44
+ ...(args.wallet === undefined ? [] : [buildWalletContent(args.wallet)]),
45
+ ];
36
46
  const { acct, conv } = await convOf(line);
37
47
  if (!conv) throw noConv(line);
38
48
  let messageId: string | undefined;
39
- if (text || !built) {
49
+ if (text || parts.length === 0) {
40
50
  messageId = await conv.sendText(text);
41
51
  emitOutbound(acct.cfg.id, line, messageId, text);
42
52
  }
43
- if (built) {
44
- messageId = await conv.send(new FrameCodec().encode(built.frame));
45
- emitOutbound(acct.cfg.id, line, messageId, `Frame: ${built.title}`);
53
+ for (const part of parts) {
54
+ messageId = await conv.send(part.encoded);
55
+ emitOutbound(acct.cfg.id, line, messageId, part.summary);
46
56
  }
47
57
  respond(id, { result: { messageId } });
48
58
  }
@@ -166,6 +166,24 @@ export const SignatureReferenceCodec =
166
166
  c.signature ? `[Signature] ${c.signature}` : '[Signature]',
167
167
  );
168
168
 
169
+ const xmtpOrg = (typeId: string): ContentTypeId => ({
170
+ authorityId: 'xmtp.org',
171
+ typeId,
172
+ versionMajor: 1,
173
+ versionMinor: 0,
174
+ });
175
+ export const WalletSendCallsCodec = makeJsonCodec<{ calls?: { metadata?: { description?: string } }[] }>(
176
+ xmtpOrg('walletSendCalls'),
177
+ (c) => {
178
+ const desc = c.calls?.[0]?.metadata?.description;
179
+ return desc ? `[Transaction request] ${desc}` : '[Transaction request]';
180
+ },
181
+ );
182
+ export const TransactionReferenceCodec = makeJsonCodec<{ reference?: string }>(
183
+ xmtpOrg('transactionReference'),
184
+ (c) => (c.reference ? `[Transaction] ${c.reference}` : '[Transaction]'),
185
+ );
186
+
169
187
  export const ContentTypeFrame: ContentTypeId = {
170
188
  authorityId: 'stage.box',
171
189
  typeId: 'frame',
@@ -89,7 +89,7 @@ export async function buildGroupInfo(
89
89
  const gn = (conv as unknown as { name?: string | (() => Promise<string>) })
90
90
  .name;
91
91
  const resolvedName = typeof gn === 'function' ? await gn() : (gn ?? '');
92
- const { labels, github, preview } = readAppData(rawAppData);
92
+ const { labels, category, github, preview } = readAppData(rawAppData);
93
93
  return {
94
94
  line,
95
95
  id: conv.id,
@@ -98,6 +98,7 @@ export async function buildGroupInfo(
98
98
  name: resolvedName ?? '',
99
99
  memberCount: inboxIds.length,
100
100
  labels,
101
+ category,
101
102
  github,
102
103
  preview,
103
104
  assigned: Object.hasOwn(appData, 'assigned') ? appData.assigned : [],
@@ -12,10 +12,10 @@ const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
12
12
 
13
13
  type Node = Record<string, unknown>;
14
14
 
15
- const isNode = (v: unknown): v is Node =>
15
+ export const isNode = (v: unknown): v is Node =>
16
16
  typeof v === 'object' && v !== null && !Array.isArray(v);
17
17
 
18
- const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
18
+ export const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
19
19
 
20
20
  function plain(value: unknown): string {
21
21
  if (typeof value !== 'string') return '';
@@ -56,7 +56,7 @@ function clip(value: unknown, max: number, name: string): string | undefined {
56
56
  return out === '' ? undefined : out;
57
57
  }
58
58
 
59
- function parseJson(raw: unknown, name: string): unknown {
59
+ export function parseJson(raw: unknown, name: string): unknown {
60
60
  if (typeof raw !== 'string') return raw;
61
61
  try {
62
62
  return JSON.parse(raw);
@@ -14,13 +14,17 @@ const MAX_LABELS = 16;
14
14
  const MAX_LABEL_LEN = 24;
15
15
  const MAX_APP_DATA_BYTES = 8192;
16
16
 
17
+ function cleanLabel(raw: string): string {
18
+ return raw.trim().replace(/\s+/g, ' ').slice(0, MAX_LABEL_LEN);
19
+ }
20
+
17
21
  function cleanLabels(raw: unknown): string[] {
18
22
  if (!Array.isArray(raw)) return [];
19
23
  const out: string[] = [];
20
24
  const seen = new Set<string>();
21
25
  for (const item of raw) {
22
26
  if (typeof item !== 'string') continue;
23
- const label = item.trim().replace(/\s+/g, ' ').slice(0, MAX_LABEL_LEN);
27
+ const label = cleanLabel(item);
24
28
  const key = label.toLowerCase();
25
29
  if (!label || seen.has(key)) continue;
26
30
  seen.add(key);
@@ -70,8 +74,13 @@ function trimmedString(v: unknown): string | undefined {
70
74
  return typeof v === 'string' && v.trim() ? v.trim() : undefined;
71
75
  }
72
76
 
77
+ function cleanCategory(v: unknown): string | undefined {
78
+ return typeof v === 'string' ? cleanLabel(v) || undefined : undefined;
79
+ }
80
+
73
81
  export function readAppData(appData: string | undefined): {
74
82
  labels: string[];
83
+ category?: string;
75
84
  github?: string;
76
85
  preview?: string;
77
86
  } {
@@ -82,6 +91,7 @@ export function readAppData(appData: string | undefined): {
82
91
  const rec = p as Record<string, unknown>;
83
92
  return {
84
93
  labels: cleanLabels(rec.labels),
94
+ category: cleanCategory(rec.category),
85
95
  github: trimmedString(rec.github),
86
96
  preview: trimmedString(rec.preview),
87
97
  };
@@ -97,33 +107,30 @@ function checkedLabels(value: unknown): string[] {
97
107
  return cleanLabels(value);
98
108
  }
99
109
 
110
+ function checkedCategory(value: unknown): string | undefined {
111
+ if (value !== undefined && value !== null && typeof value !== 'string') {
112
+ throw new TrainError('INVALID_ARGS', 'category must be a string or null');
113
+ }
114
+ return cleanCategory(value);
115
+ }
116
+
117
+ const KNOWN_FIELDS = new Map<string, (value: unknown) => unknown>([
118
+ ['labels', checkedLabels],
119
+ ['assigned', normalizeAssigned],
120
+ ['github', (value) => normalizeGithubUrl(value) || undefined],
121
+ ['preview', (value) => normalizePreviewUrl(value) || undefined],
122
+ ['category', checkedCategory],
123
+ ]);
124
+
100
125
  function applyMergeKey(
101
126
  merged: Record<string, unknown>,
102
127
  k: string,
103
128
  v: unknown,
104
129
  ): void {
105
- if (k === 'labels') {
106
- merged.labels = checkedLabels(v);
107
- return;
108
- }
109
- if (k === 'assigned') {
110
- merged.assigned = normalizeAssigned(v);
111
- return;
112
- }
113
- if (k === 'github') {
114
- const g = normalizeGithubUrl(v);
115
- if (g) merged.github = g;
116
- else delete merged.github;
117
- return;
118
- }
119
- if (k === 'preview') {
120
- const p = normalizePreviewUrl(v);
121
- if (p) merged.preview = p;
122
- else delete merged.preview;
123
- return;
124
- }
125
- if (v === undefined || v === null) Reflect.deleteProperty(merged, k);
126
- else Object.defineProperty(merged, k, { value: v, enumerable: true, configurable: true, writable: true });
130
+ const normalize = KNOWN_FIELDS.get(k);
131
+ const value = normalize ? normalize(v) : v;
132
+ if (value === undefined || value === null) Reflect.deleteProperty(merged, k);
133
+ else Object.defineProperty(merged, k, { value, enumerable: true, configurable: true, writable: true });
127
134
  }
128
135
 
129
136
  export function mergeAppData(
@@ -114,7 +114,7 @@ export const XMTP_TOOLS: StationTool[] = [
114
114
  group: 'read',
115
115
  description:
116
116
  "Read an XMTP channel's current metadata + membership. Args: line (required). Returns " +
117
- '{line, id, account, version (dm|group), name, memberCount, labels, github, preview, ' +
117
+ '{line, id, account, version (dm|group), name, memberCount, labels, category, github, preview, ' +
118
118
  'assigned, appData (stored JSON object), rawAppData (stored JSON string), ' +
119
119
  'members:[{inboxId, address}]}. Syncs first and fails if metadata cannot be read. ' +
120
120
  'xmtp-only (daemon `groupInfo`). Use before ' +
@@ -171,10 +171,11 @@ export const XMTP_TOOLS: StationTool[] = [
171
171
  "Update an existing channel's metadata. Args: line (required, the metro:// line), and " +
172
172
  'any of metadata? (JSON object), github? (url), preview? (url), name? (string). ' +
173
173
  'metadata is a shallow appData patch: omitted keys are preserved, supplied nested ' +
174
- 'values replace that key, and null deletes custom keys. v is reserved. Put labels ' +
175
- 'and assigned inside metadata. assigned replaces the array with normalized lowercase ' +
174
+ 'values replace that key, and null deletes custom keys. v is reserved. Put labels, ' +
175
+ 'assigned and category inside metadata. assigned replaces the array with normalized lowercase ' +
176
176
  'Ethereum addresses of current members. Read group_info and union existing IDs to ' +
177
- 'add assignees. labels remains a legacy top-level alias. Duplicate top-level/metadata ' +
177
+ 'add assignees. category is one string per channel (24 characters at most); "" or null ' +
178
+ 'clears it. labels remains a legacy top-level alias. Duplicate top-level/metadata ' +
178
179
  'label/link aliases are refused. All fields use one updateChannelMeta call. xmtp-only. ' +
179
180
  'Returns updated metadata. Reread group_info to verify persistence.',
180
181
  inputSchema: {
@@ -191,6 +192,10 @@ export const XMTP_TOOLS: StationTool[] = [
191
192
  description: 'Replacement assignee list. Current member Ethereum addresses only. [] clears.',
192
193
  items: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' },
193
194
  },
195
+ category: {
196
+ type: 'string',
197
+ description: 'The one category of the channel, 24 characters at most. "" clears it.',
198
+ },
194
199
  github: { type: 'string' },
195
200
  preview: { type: 'string' },
196
201
  },
@@ -0,0 +1,28 @@
1
+ import type { EncodedContent } from '@xmtp/content-type-primitives';
2
+ import {
3
+ SignatureReferenceCodec,
4
+ SignatureRequestCodec,
5
+ TransactionReferenceCodec,
6
+ WalletSendCallsCodec,
7
+ } from './codecs.js';
8
+ import { bad, isNode, parseJson } from './frames.js';
9
+
10
+ const WALLET_TYPES: Record<string, { codec: { encode(c: Record<string, unknown>): EncodedContent }; needs: string[] }> = {
11
+ walletSendCalls: { codec: new WalletSendCallsCodec(), needs: ['version', 'chainId', 'from', 'calls'] },
12
+ transactionReference: { codec: new TransactionReferenceCodec(), needs: ['networkId', 'reference'] },
13
+ signatureRequest: { codec: new SignatureRequestCodec(), needs: ['id', 'kind'] },
14
+ signatureReference: { codec: new SignatureReferenceCodec(), needs: ['requestId', 'signature', 'signer'] },
15
+ };
16
+
17
+ export function buildWalletContent(raw: unknown): { encoded: EncodedContent; summary: string } {
18
+ const args = parseJson(raw, 'wallet');
19
+ const type = isNode(args) && typeof args.type === 'string' ? args.type : '';
20
+ const spec = Object.hasOwn(WALLET_TYPES, type) ? WALLET_TYPES[type] : undefined;
21
+ if (!isNode(args) || !spec) throw bad(`wallet type must be one of ${Object.keys(WALLET_TYPES).join(', ')}`);
22
+ const content = parseJson(args.content, 'wallet content');
23
+ if (!isNode(content)) throw bad(`wallet content must be the ${type} JSON object`);
24
+ const missing = spec.needs.filter((key) => content[key] === undefined);
25
+ if (missing.length > 0) throw bad(`${type} content needs ${missing.join(', ')}`);
26
+ const encoded = spec.codec.encode(content);
27
+ return { encoded, summary: encoded.fallback ?? type };
28
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.247"
2
+ "version": "0.1.0-beta.249"
3
3
  }