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

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.248",
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.248",
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>`).
@@ -73,3 +73,17 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
73
73
  ]}}
74
74
  }}}
75
75
  ```
76
+
77
+ **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.
78
+ - 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.
79
+ - Payment request, `walletSendCalls`, a card with a Pay button. Stage pays a request with one call; a Stage wallet pays on Base only.
80
+ ```json
81
+ {"type": "walletSendCalls", "content": {"version": "1.0", "chainId": "0x2105", "from": "0x<your address>", "calls": [
82
+ {"to": "0x<who gets paid>", "value": "0x38d7ea4c68000", "metadata": {"description": "Dinner", "transactionType": "transfer",
83
+ "currency": "ETH", "amount": 0.001, "decimals": 18, "toAddress": "0x<who gets paid>"}}
84
+ ]}}
85
+ ```
86
+ `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).
87
+ - 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.
88
+ - 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`.
89
+ - 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
  },
@@ -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',
@@ -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);
@@ -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.248"
3
3
  }