@audienti/cli 0.2.0-exora.5 → 0.2.0-exora.7

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.
@@ -5,7 +5,7 @@ const item = { ...scope, evidenceId: uuid };
5
5
  const status = z.enum(['needs_handling', 'handled', 'not_reply']);
6
6
  const outcome = z.enum(['response_recorded', 'closed_without_response', 'suppressed']);
7
7
  const limit = z.number().int().min(1).max(100);
8
- const classification = z.enum(['interested', 'question', 'later', 'out_of_office', 'stop', 'bounce', 'referral']);
8
+ const classification = z.enum(['interested', 'question', 'later', 'out_of_office', 'stop', 'bounce', 'referral', 'wrong_person']);
9
9
  const slot = z.object({ start: z.string().describe('UTC start, ending in Z.'), end: z.string().describe('UTC end, ending in Z.'), timeZone: z.string().describe("The user's IANA time zone, for display.") }).strict();
10
10
  const draft = z.object({
11
11
  type: z.enum(['email.send', 'linkedin.send']),
@@ -34,11 +34,13 @@ export const accountReplyOperations = {
34
34
  workspace_reply_set: {
35
35
  schema: z.object({ ...item, requestId: uuid, expectedVersion: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), status,
36
36
  outcome: outcome.nullable().describe("Null for needs_handling/not_reply; a handling outcome is required for handled."), reason: z.string().meta({ minLength: 1, maxLength: 2000 }).describe("Required trimmed NFC reason, 1–2000 codepoints. Interior control characters including newline and tab are rejected."), responseEvidenceId: uuid.nullable().describe("Required for response_recorded; null otherwise. Must identify matching outbound account evidence."),
37
- classification: classification.optional().describe("What the reply is. Records a replied event on the person's active campaigns, except for bounce and out_of_office."),
37
+ classification: classification.optional().describe("What the reply is. Records a replied event on the person's active campaigns, except for bounce and out_of_office. An out_of_office reply snoozes the person instead; a wrong_person reply with no name given stops the sends and asks who the right person is. Someone who names the right person is a referral."),
38
+ returnsOn: z.string().datetime().optional().describe("Only with out_of_office: the date the person is back (UTC, ending in Z). The snooze lasts until then; with no date it follows the playbook's out-of-office rule, 7 days by default."),
38
39
  invite: z.literal(true).optional().describe("The prospect sent a calendar invite you have not processed yet. Requires a classification; a meetings.confirm task follows."),
39
40
  draft: draft.optional().describe("Your drafted answer. Saved as a reply plan revision the user reviews in Operator; requires status needs_handling. Never sent until approved."),
40
41
  result: result.optional().describe("Brief 12: the typed detail for a bounce, stop or referral. Its type must match the classification."),
41
- meeting: z.object({ at: z.string().datetime(), source: z.enum(['sent_invite', 'received_invite']) }).strict().optional().describe("A booked meeting: when, and whether the user's calendar sent the invite or the prospect did. Records meeting_booked.") }).strict().describe("Explicit inbound classification. needs_handling/not_reply require null outcome and responseEvidenceId. handled requires an outcome; response_recorded requires matching outbound evidence, closed_without_response an explicit closure reason, and suppressed active DNC or Reject."),
42
+ meeting: z.object({ at: z.string().datetime(), source: z.enum(['sent_invite', 'received_invite']) }).strict().optional().describe("A booked meeting: when, and whether the user's calendar sent the invite or the prospect did. Records meeting_booked."),
43
+ meetingState: z.enum(['requested', 'accepted', 'declined']).optional().describe("Where a meeting that is not booked yet stands after this reply: requested (a time was asked for), accepted (they agreed, no time yet) or declined. Records the matching meeting event once. Ignored once a meeting is booked.") }).strict().describe("Explicit inbound classification. needs_handling/not_reply require null outcome and responseEvidenceId. handled requires an outcome; response_recorded requires matching outbound evidence, closed_without_response an explicit closure reason, and suppressed active DNC or Reject."),
42
44
  method: 'POST', scoped: true, path: detail,
43
45
  body: ({ workspaceId, prospectId, evidenceId, ...rest }) => rest,
44
46
  },
package/cli/agents.mjs CHANGED
@@ -1,14 +1,16 @@
1
- import { readProfile, writeProfile, validateUrl, configuration, WorkspaceRequestError } from './workspaces.mjs';
1
+ import { readProfile, writeProfile, validateUrl, configuration, profileFrom, WorkspaceRequestError } from './workspaces.mjs';
2
2
  import { z } from 'zod';
3
3
  import {readJsonFile} from './json-file.mjs';
4
4
 
5
5
  export const agentManagementOperations = ['agent_list','agent_revoke','agent_permission','agent_capabilities_set','agent_capabilities_get','agent_inventory_get','agent_capability_sharing_get','agent_capability_sharing_set','agent_account_authorizations_get','agent_account_authorization_set'];
6
+ // The seven capability groups and the 'approve' guardrail (#120). 'listen' is the retired name of 'research'; the server still accepts it.
7
+ const capabilityKeys=['find','enrich','expand','research','engage','sync','notify','approve','listen'];
6
8
  const inventoryLabel=z.string().trim().min(1).max(80);
7
9
  // Capability rows (ADR 015). The server checks ids, providers and access against the catalog; the client checks the shape.
8
10
  const capabilityRow=z.object({capability:z.string().min(1).max(64),provider:z.string().min(1).max(32).optional(),via:z.string().min(1).max(140),account:z.string().min(1).max(200).optional(),access:z.enum(['read','write','send']),cost:z.enum(['owner','credits']).optional(),status:z.enum(['connected','needs_sign_in','unavailable']),limits:z.object({daily:z.number().int().min(0).max(100000).optional(),hourly:z.number().int().min(0).max(100000).optional()}).strict().optional(),identity:z.object({namespace:z.string().min(1).max(64),accountId:z.string().min(1).max(256)}).strict().optional()}).strict();
9
11
  // A LinkedIn seat's cadence, InMail and safe limits (brief 09); the server checks the bounds.
10
12
  const seatLimit=z.number().int().min(0).max(100000);
11
- const seatFacts={checkEverySeconds:z.number().int().min(3600).max(604800).optional(),inmail:z.object({available:z.boolean(),quotaPerMonth:seatLimit.optional()}).strict().optional(),limits:z.object({connectionRequestsPerDay:seatLimit.optional(),connectionRequestsPerWeek:seatLimit.optional(),messagesPerDay:seatLimit.optional(),inmailPerMonth:seatLimit.optional()}).strict().optional()};
13
+ const seatFacts={checkEverySeconds:z.number().int().min(3600).max(604800).optional(),inmail:z.object({available:z.boolean(),quotaPerMonth:seatLimit.optional(),creditsLeft:seatLimit.optional()}).strict().optional(),plan:z.enum(['free','premium_career','premium_business','premium_all_in_one','sales_navigator_core','sales_navigator_advanced','recruiter_lite']).optional(),limits:z.object({connectionRequestsPerDay:seatLimit.optional(),connectionRequestsPerWeek:seatLimit.optional(),messagesPerDay:seatLimit.optional(),inmailPerMonth:seatLimit.optional()}).strict().optional()};
12
14
  const accountEntry=z.object({capability:z.string(),tools:z.array(z.string()).min(1).max(8)}).strict();
13
15
  const accountReport=z.object({provider:z.enum(['email','linkedin']),accountRef:z.string().min(1).max(200),status:z.enum(['healthy','unavailable','disconnected']),accountIdentity:z.object({namespace:z.string(),accountId:z.string()}).strict().optional(),capabilities:z.array(accountEntry).max(16),...seatFacts}).strict();
14
16
  const capabilityReport=z.object({runtime:z.object({name:z.string().trim().min(1).max(80),model:z.string().trim().min(1).max(80).optional()}).strict(),capabilities:z.array(capabilityRow).max(64),seats:z.array(z.object({provider:z.literal('linkedin'),account:z.string().min(1).max(200),...seatFacts}).strict()).max(50).optional()}).strict();
@@ -24,13 +26,13 @@ export const agentOperations = {
24
26
  agent_capability_sharing_get: {schema:z.object({id:z.string().uuid()}).strict()},
25
27
  agent_capability_sharing_set: {schema:z.object({id:z.string().uuid(),provider:z.enum(['email','linkedin']),accountRef:z.string().min(1).max(200),workspaceId:z.string().uuid(),shared:z.boolean()}).strict()},
26
28
  // One object schema for MCP clients; callAgent then requires exactly one of the two report shapes.
27
- agent_capabilities_report: {description:'Getting started step 2: report what this agent can do as capability rows ({ runtime, capabilities: [{ capability, provider?, via, account?, access, cost?, status, limits?, identity? }], seats?: [{ provider: "linkedin", account, checkEverySeconds?, inmail?, limits? }] }), ids from the capability catalog. A LinkedIn account sends only when its rows carry identity { namespace: "linkedin", accountId: the member id }; a mailbox without one is matched by its address. Replaces the previous report. The old per-account shape (provider, accountRef, status, capabilities) is still accepted. Never use via "audienti" and never include credentials. Requires an active agent profile.',schema:z.object({runtime:capabilityReport.shape.runtime.optional(),provider:accountReport.shape.provider.optional(),accountRef:accountReport.shape.accountRef.optional(),status:accountReport.shape.status.optional(),accountIdentity:accountReport.shape.accountIdentity,capabilities:z.array(z.union([capabilityRow,accountEntry])).max(64),seats:capabilityReport.shape.seats,...seatFacts}).strict(),shape:eitherReport},
29
+ agent_capabilities_report: {description:'Getting started step 2: report what this agent can do as capability rows ({ runtime, capabilities: [{ capability, provider?, via, account?, access, cost?, status, limits?, identity? }], seats?: [{ provider: "linkedin", account, checkEverySeconds?, plan?, inmail?, limits? }] }), ids from the capability catalog. A LinkedIn account sends only when its rows carry identity { namespace: "linkedin", accountId: the member id }; a mailbox without one is matched by its address. Replaces the previous report. The old per-account shape (provider, accountRef, status, capabilities) is still accepted. Never use via "audienti" and never include credentials. Requires an active agent profile.',schema:z.object({runtime:capabilityReport.shape.runtime.optional(),provider:accountReport.shape.provider.optional(),accountRef:accountReport.shape.accountRef.optional(),status:accountReport.shape.status.optional(),accountIdentity:accountReport.shape.accountIdentity,capabilities:z.array(z.union([capabilityRow,accountEntry])).max(64),seats:capabilityReport.shape.seats,...seatFacts}).strict(),shape:eitherReport},
28
30
  agent_capabilities_list: {description:'Read what this agent can do now: its own capability rows plus Audienti hosted rows for the workspace (when the workspace has credits and the user approved the group). Uses the profile workspace unless workspaceId is given. Requires an active agent profile.',schema:z.object({workspaceId:z.string().uuid().optional()}).strict()},
29
31
  agent_capabilities_show: {schema:z.object({}).strict()},
30
32
  agent_capabilities_get: {schema:z.object({id:z.string().uuid()}).strict()},
31
33
  agent_permission: {schema:z.object({id:z.string().uuid(),scope:z.enum(['workspace:read','workspace:access'])}).strict()},
32
- agent_capabilities_set: {schema:z.object({id:z.string().uuid(),capabilities:z.array(z.enum(['find','enrich','listen','approve'])).min(1).max(4)}).strict()},
33
- agent_connect: { schema: z.object({ name: z.string().trim().min(1).max(80), capabilities: z.array(z.enum(['find','enrich','listen','approve'])).max(4).optional() }).strict() },
34
+ agent_capabilities_set: {schema:z.object({id:z.string().uuid(),capabilities:z.array(z.enum(capabilityKeys)).min(1).max(9)}).strict()},
35
+ agent_connect: { schema: z.object({ name: z.string().trim().min(1).max(80), capabilities: z.array(z.enum(capabilityKeys)).max(9).optional() }).strict() },
34
36
  agent_poll: { schema: z.object({}).strict() },
35
37
  agent_cancel: { schema: z.object({}).strict() },
36
38
  agent_list: { schema: z.object({}).strict() },
@@ -139,7 +141,7 @@ export async function agentCli(args) {
139
141
  else if (positions[2] === undefined && !flags.file && !flags.id) {name='agent_capabilities_list';input=flags.workspace ? {workspaceId:flags.workspace} : {};}
140
142
  else throw new Error('Use agent capabilities [--workspace ID] [--json], report --file PATH or show [--id ID].');
141
143
  }
142
- const result = await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), flags.profile);
144
+ const result = await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), profileFrom(flags.profile));
143
145
  console.log(name === 'agent_capabilities_list' && !json ? capabilityText(result.data) : JSON.stringify(result));
144
146
  }
145
147
 
@@ -0,0 +1,9 @@
1
+ import {z} from 'zod';
2
+ // #413: a file is uploaded first and named by id in a LinkedIn message (content.attachments). An image is at most 5 MB;
3
+ // a voice note is at most 2 minutes and 5 MB.
4
+ const uuid=z.string().uuid(),maxBytes=5*1024*1024;
5
+ const base64=z.string().min(4).max(4*Math.ceil(maxBytes/3)+8).refine(value=>value.length%4===0&&/^[A-Za-z0-9+/]+={0,2}$/.test(value));
6
+ export const attachmentOperations={
7
+ attachment_upload:{schema:z.object({workspaceId:uuid.optional(),name:z.string().trim().min(1).max(255),contentType:z.string().max(127),contentBase64:base64,durationSeconds:z.number().int().positive().max(120).optional()}).strict(),method:'POST',path:a=>`${a.workspaceId?`/workspaces/${a.workspaceId}`:''}/attachments`,body:a=>({name:a.name,contentType:a.contentType,contentBase64:a.contentBase64,...(a.durationSeconds?{durationSeconds:a.durationSeconds}:{})}),agentWide:true,description:'Store an image (jpeg, png, gif, webp; at most 5 MB) or a voice note (mpeg, mp4, ogg, wav, webm; at most 5 MB and 2 minutes) and return its id. Name the id in content.attachments of a linkedin.send action tagged profile.message.send. A voice note needs durationSeconds and goes only to a connection. Needs workspace:access.'},
8
+ attachment_get:{schema:z.object({workspaceId:uuid.optional(),attachmentId:uuid}).strict(),method:'GET',path:a=>`${a.workspaceId?`/workspaces/${a.workspaceId}`:''}/attachments/${a.attachmentId}`,agentWide:true,description:'One uploaded file with its content as base64. An agent that sends from its own LinkedIn account fetches the file here, then attaches it in LinkedIn.'},
9
+ };
@@ -0,0 +1,28 @@
1
+ import {readFileSync,writeFileSync,statSync} from 'node:fs';
2
+ import {basename,extname} from 'node:path';
3
+ import {callWorkspace,configuration} from './workspaces.mjs';
4
+ const help='Use attachments upload FILE [--type MIME] [--name NAME] [--duration SECONDS] [--workspace ID] [--profile PATH], or attachments get ATTACHMENT_ID --out PATH [--workspace ID] [--profile PATH]. An image is at most 5 MB; a voice note is at most 2 minutes and 5 MB, and its length goes in --duration.';
5
+ const TYPES={'.jpg':'image/jpeg','.jpeg':'image/jpeg','.png':'image/png','.gif':'image/gif','.webp':'image/webp','.mp3':'audio/mpeg','.m4a':'audio/mp4','.ogg':'audio/ogg','.wav':'audio/wav'};
6
+ export async function attachmentsCli(args){
7
+ const position=[],flags={};for(let i=1;i<args.length;i++){
8
+ if(!args[i].startsWith('--')){position.push(args[i]);continue}
9
+ const key=args[i].slice(2);if(key==='json')continue;if(!['profile','workspace','type','name','duration','out'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error(`Unknown or incomplete attachments option. ${help}`);
10
+ flags[key]=args[++i];
11
+ }
12
+ const config=configuration(flags.profile);
13
+ if(position[0]==='upload'&&position.length===2){
14
+ const file=position[1],type=flags.type??TYPES[extname(file).toLowerCase()];
15
+ if(!type)throw new Error(`Give --type for this file. ${help}`);
16
+ const stat=statSync(file);if(!stat.isFile()||stat.size>5*1024*1024)throw new Error('The file must be a regular file of at most 5 MB.');
17
+ const duration=flags.duration===undefined?undefined:Number(flags.duration);
18
+ if(duration!==undefined&&!Number.isInteger(duration))throw new Error('--duration is a whole number of seconds.');
19
+ const result=await callWorkspace('attachment_upload',{workspaceId:flags.workspace,name:flags.name??basename(file),contentType:type,contentBase64:readFileSync(file).toString('base64'),...(duration===undefined?{}:{durationSeconds:duration})},config);
20
+ console.log(JSON.stringify(result.data));return;
21
+ }
22
+ if(position[0]==='get'&&position.length===2&&flags.out){
23
+ const result=await callWorkspace('attachment_get',{workspaceId:flags.workspace,attachmentId:position[1]},config);
24
+ const {contentBase64,...rest}=result.data;writeFileSync(flags.out,Buffer.from(contentBase64,'base64'));
25
+ console.log(JSON.stringify({...rest,savedTo:flags.out}));return;
26
+ }
27
+ throw new Error(help);
28
+ }
package/cli/audienti.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { isAssignmentCommand, assignmentCli } from './assignment.mjs';
3
+ import { playbookHelp } from './playbook-help.mjs';
3
4
  // The CLI is an HTTP client. It has no database access or separate business rules.
4
5
  const args = process.argv.slice(2);
5
6
  const usage = `Profiles: audienti profiles list|search|get ID | match evaluate|apply SOURCE CANDIDATE | history RESOLUTION (saved workspace records)
@@ -24,9 +25,9 @@ Required environment:
24
25
  AUDIENTI_API_TOKEN Account API token (create it from your Account page)
25
26
 
26
27
  Use HTTPS for remote hosts. Local HTTP is supported on loopback only.
27
- Agent commands: agent connect --name NAME [--capabilities find,enrich,listen,approve]|poll|cancel|list|revoke ID --profile PATH
28
+ Agent commands: agent connect --name NAME [--capabilities find,enrich,expand,research,engage,sync,notify,approve]|poll|cancel|list|revoke ID --profile PATH
28
29
  Owner permission change: agent permission ID --scope workspace:read|workspace:access --profile USER_PROFILE
29
- Owner capability change: agent capabilities ID --set find,enrich,listen,approve --profile USER_PROFILE (agents are refused outside this set)
30
+ Owner capability change: agent capabilities ID --set find,enrich,expand,research,engage,sync,notify,approve --profile USER_PROFILE (agents are refused outside this set)
30
31
  Agent capabilities: agent capabilities report --file CAPABILITIES.json --profile AGENT_PROFILE (capability rows; the old per-account shape still works)
31
32
  What the agent can do: agent capabilities [--workspace ID] [--json] --profile AGENT_PROFILE (works now, and works with Audienti tools)
32
33
  Read capabilities: agent capabilities show [--id AGENT_ID] --profile PROFILE
@@ -49,7 +50,8 @@ Audiences: audience options --kind industries|locations|sizes|types|seniorities|
49
50
  audience summary [--audience ID]|people ID|companies ID
50
51
  audience test ID --url LINKEDIN_URL --request-id UUID (spends hosted credits unless the profile is saved)
51
52
  Files: file upload PATH --type MIME|list|prepare|register|download|delete ID (personal files, human token only)
52
- Queue: audienti queue [list]|next|claim ID [--note TEXT]|done ID [--note TEXT]|skip ID --note TEXT|release ID [--workspace ID] (start here after you connect; without --workspace an agent reads every workspace its user approved it in)
53
+ Queue: audienti queue [list]|next|claim ID [--note TEXT]|done ID [--note TEXT]|skip ID --note TEXT|release ID [--workspace ID] (start here after you connect; without --workspace an agent reads every workspace its user approved it in; done or skip again with a new --note corrects the note of a finished task)
54
+ Attachments: audienti attachments upload FILE [--duration SECONDS] (store an image or voice note, get its id for content.attachments)
53
55
  Outcome checks: audienti outcomes report TASK_ID --file RESULTS.json (results for the people an outcomes.check task lists)
54
56
  Questions: audienti ask "QUESTION" [--context TEXT] [--option TEXT]... [--campaign ID] [--task ID] (questions for your user go to Operator, not chat)
55
57
  audienti questions [list] [--state open|answered|withdrawn|all] | show ID | withdraw ID | answer ID --answer TEXT
@@ -61,7 +63,7 @@ Offer options: --file FIELDS.json --request-id UUID --expected-revision N --revi
61
63
  Account intake: prospects intake --workspace ID --file INPUT.json
62
64
  prospects conversation append|list PERSON --workspace ID
63
65
  Discovery: prospects list|get ID|timeline ID [--limit N]|observation create|citation create|evidence create
64
- prospects contacts list|record PERSON [--file INPUT.json] --workspace ID
66
+ prospects contacts list|record PERSON [--file INPUT.json] [--request-id UUID] --workspace ID
65
67
  prospects contacts get|retire PERSON CONTACT --workspace ID
66
68
  prospects suppression get|set ID --workspace ID
67
69
  guardrails show|pause|resume --workspace ID
@@ -69,7 +71,7 @@ Discovery: prospects list|get ID|timeline ID [--limit N]|observation create|cita
69
71
  dnc import --file LIST.csv --workspace ID (at most 5,000 rows; reports added, skipped and invalid rows)
70
72
  dnc export [--output FILE.csv] --workspace ID
71
73
  secret list [--workspace ID] (names only)
72
- secret get NAME [--workspace ID] [--json] (prints the value; every read is logged)
74
+ secret get NAME [--workspace ID] [--json] (prints the value; the secret records who read it last and when)
73
75
  budgets show --workspace ID
74
76
  budgets alerts --workspace ID [--limit N]
75
77
  usage breakdown [--workspace ID] [--since month|7d|30d|90d] [--by tool|provider|model|caller|day] [--format json|csv]
@@ -78,7 +80,9 @@ Discovery: prospects list|get ID|timeline ID [--limit N]|observation create|cita
78
80
  Qualification: campaigns qualification submit --file INPUT.json
79
81
  Find prospects: campaign find CAMPAIGN_ID --count N --max-credits C (searches with the audience filters, checks each person, admits only fits; stops at N fits or the credit cap and reports the spend)
80
82
  campaign check CAMPAIGN_ID --url LINKEDIN_URL [--request-id UUID] (checks one person the same way; reuse the request ID after an interruption)
81
- Playbooks: playbook list|show ID|save --file REQUEST.json|progress ID|record --file REQUEST.json
83
+ Target companies: campaign targets CAMPAIGN_ID [--limit N --after CURSOR] (reads the companies people must work at to join)
84
+ campaign targets CAMPAIGN_ID --add acme.com,linkedin.com/company/globex [--remove old.com] | --set FILE|- (one company per line; saves a new revision)
85
+ Playbooks: playbook list [--limit N --cursor ID]|show ID [--revision N]|save --file REQUEST.json|progress PARTICIPATION_ID|record --file REQUEST.json|suggest --file REQUEST.json|suggestions|decide --file REQUEST.json (audienti playbook SUBCOMMAND --help for the request shape)
82
86
  Analytics: analytics show --from ISO --to ISO [--campaign ID] [--playbook-version ID] [--format json|csv]
83
87
  analytics outbound [--workspace ID] [--days N | --from ISO --to ISO] [--by sender|action|day] [--format json|csv]
84
88
  analytics activity [--workspace ID] [--days N | --from ISO --to ISO] [--format json|csv]
@@ -95,7 +99,7 @@ Read state: workspace notification-read --workspace ID --key KEY --read true|fal
95
99
  Search: workspace search --workspace ID --query TEXT [--people-only true] [--recent FILE.json]
96
100
  Invitation commands: workspace invitation show|accept|decline|retry|cancel ID
97
101
  Options: --workspace ID --name NAME --request-id UUID --email EMAIL --generation N
98
- Optional private profile: --profile PATH (outside this repository)
102
+ Optional private profile: --profile PATH (outside this repository), or the AUDIENTI_PROFILE variable. --profile wins.
99
103
  Save an environment token: profile save --scope account:read|workspace:access|agents:manage --profile PATH
100
104
  Start local MCP stdio server: mcp
101
105
  --json writes the API response envelope.
@@ -103,7 +107,7 @@ Start local MCP stdio server: mcp
103
107
 
104
108
  // Example inputs for the commands that save prospects, so `--help` shows a working shape.
105
109
  const examples = {
106
- 'prospects observation create': { requestId: 'UUID', kind: 'person', source: { provider: 'web', url: 'https://example.com/team/jane' }, fields: { name: 'Jane Doe', title: 'VP Sales', imageUrl: 'https://media.licdn.com/dms/image/PHOTO_ID' }, provenance: { url: 'https://example.com/team/jane' }, observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
110
+ 'prospects observation create': { requestId: 'UUID', kind: 'person', source: { provider: 'linkedin', url: 'https://www.linkedin.com/in/jane-doe' }, fields: { name: 'Jane Doe', title: 'VP Sales', imageUrl: 'https://media.licdn.com/dms/image/PHOTO_ID' }, provenance: { url: 'https://example.com/team/jane' }, observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
107
111
  'prospects citation create': { requestId: 'UUID', source: { kind: 'prospect', id: 'PERSON_PROSPECT_ID' }, target: { kind: 'prospect', id: 'COMPANY_PROSPECT_ID' }, edgeType: 'works_at', roles: [{ roleId: 'role-1', title: 'VP Sales', current: true, observedAt: '2026-01-01T00:00:00Z' }] },
108
112
  'prospects evidence create': { requestId: 'UUID', subject: { kind: 'person', prospectId: 'PROSPECT_ID' }, campaignId: 'CAMPAIGN_ID', campaignRevision: 1, kind: 'interpretation', data: { conclusion: 'Leads a 40-person sales team', reason: 'Team page lists her as VP Sales' }, sourceUrl: 'https://example.com/team', observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
109
113
  'prospects contacts record': { kind: 'email|linkedin|mobile|phone', value: 'jane@example.com, or +15551234567 for a mobile or a direct office phone', reason: 'Published on https://example.com/contact', requestId: 'UUID', provenance: { observationId: 'OBSERVATION_ID' } },
@@ -124,6 +128,7 @@ function commandHelp() {
124
128
  }
125
129
 
126
130
  async function main() {
131
+ if (args[0] === 'playbook' && args.slice(1).some(value => value === '--help' || value === '-h')) { console.log(playbookHelp(args[1]?.startsWith('-') ? undefined : args[1])); return; }
127
132
  if (args.length > 1 && args.slice(1).some(value => value === '--help' || value === '-h') && !['tools', 'call', 'skills'].includes(args[0])) { commandHelp(); return; }
128
133
  if(args[0]==='prospects'&&['intake','conversation'].includes(args[1])){const {accountIntakeCli}=await import('./account-intake.mjs');await accountIntakeCli(args);return;}
129
134
  if(args[0]==='sender'){const {senderAccountsCli}=await import('./sender-accounts.mjs');await senderAccountsCli(args);return;}
@@ -152,6 +157,7 @@ async function main() {
152
157
  if(args[0]==='ask'){const {askCli}=await import('./questions.mjs');await askCli(args);return;}
153
158
  if(args[0]==='questions'){const {questionsCli}=await import('./questions.mjs');await questionsCli(args);return;}
154
159
  if(args[0]==='queue'){const {queueCli}=await import('./queue.mjs');await queueCli(args);return;}
160
+ if(args[0]==='attachments'){const {attachmentsCli}=await import('./attachments.mjs');await attachmentsCli(args);return;}
155
161
  if(args[0]==='outcomes'){const {outcomesCli}=await import('./outcomes.mjs');await outcomesCli(args);return;}
156
162
  if(args[0]==='rooms'){const {roomsCli}=await import('./rooms.mjs');await roomsCli(args);return;}
157
163
  if(args[0]==='kv'){const {kvCli}=await import('./kv.mjs');await kvCli(args);return;}
@@ -170,11 +176,16 @@ async function main() {
170
176
  profileConfig = configuration(args[profileIndex + 1], 'account:read');
171
177
  if (!profileConfig.token) throw new Error('whoami needs a human account token. Agents confirm their connection with: audienti workspace list --profile PATH');
172
178
  args.splice(profileIndex, 2);
179
+ } else if (args[0] === 'whoami' && profileIndex < 0 && process.env.AUDIENTI_PROFILE && !(process.env.AUDIENTI_API_URL && process.env.AUDIENTI_API_TOKEN)) {
180
+ // The profile named by AUDIENTI_PROFILE (#448), unless the URL and token are in the environment.
181
+ const { configuration } = await import('./workspaces.mjs');
182
+ profileConfig = configuration(undefined, 'account:read');
183
+ if (!profileConfig.token) throw new Error('whoami needs a human account token, and AUDIENTI_PROFILE holds none. Agents confirm their connection with: audienti workspace list');
173
184
  }
174
185
  if (args[0] !== 'whoami' || args.length > 2 || (args[1] && args[1] !== '--json')) throw new Error('Unknown command or option. Use audienti --help.');
175
186
  const rawUrl = profileConfig ? profileConfig.url : process.env.AUDIENTI_API_URL;
176
187
  const token = profileConfig ? profileConfig.token : process.env.AUDIENTI_API_TOKEN;
177
- if (!rawUrl || !token) throw new Error('Set AUDIENTI_API_URL and AUDIENTI_API_TOKEN. Create a token from your Account page.');
188
+ if (!rawUrl || !token) throw new Error('Set AUDIENTI_API_URL and AUDIENTI_API_TOKEN, or pass --profile PATH (or set AUDIENTI_PROFILE) to a profile with an account:read token. Create a token from your Account page.');
178
189
  let base;
179
190
  try { base = new URL(rawUrl); } catch { throw new Error('AUDIENTI_API_URL must be a valid API base URL.'); }
180
191
  if (base.username || base.password || base.search || base.hash) throw new Error('The API URL must not contain credentials, query parameters, or a fragment.');
@@ -200,4 +211,4 @@ async function main() {
200
211
  console.log(args[1] === '--json' ? JSON.stringify({ data: profile }) : `${profile.email ?? profile.username ?? profile.id}\nAccount ID: ${profile.id}`);
201
212
  }
202
213
 
203
- main().catch(error => { console.error(error.toJSON ? JSON.stringify({ error: error.toJSON() }) : `Audienti: ${error.message}`); process.exitCode = 1; });
214
+ main().catch(error => { console.error(JSON.stringify({ error: error.toJSON ? error.toJSON() : { code: 'client_error', message: error.message } })); process.exitCode = 1; });
@@ -7,10 +7,9 @@ const signal=z.discriminatedUnion('kind',[
7
7
  z.object({...signalBase,kind:z.literal('topic'),topic:z.string()}).strict(),
8
8
  z.object({...signalBase,kind:z.literal('profile'),url:z.string(),track:z.enum(['audience','activity']).optional(),interactions:z.array(z.enum(['comments','reactions'])).optional()}).strict(),
9
9
  ]);
10
- const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),playbookPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),targets:z.discriminatedUnion('kind',[
10
+ const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),playbookPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),method:z.enum(['signals','listen','track-audience','track-engagement','custom']).optional(),findingInstructions:z.string().optional(),targets:z.discriminatedUnion('kind',[
11
11
  z.object({kind:z.literal('none'),values:z.array(z.never())}).strict(),
12
12
  z.object({kind:z.literal('companies'),values:z.array(z.object({type:z.enum(['domain','linkedin-company']),value:z.string()}).strict())}).strict(),
13
- z.object({kind:z.literal('people'),values:z.array(z.object({type:z.literal('linkedin-person'),value:z.string()}).strict())}).strict(),
14
13
  ]).optional(),channels:z.array(z.enum(['email','linkedin'])).optional(),approvalMode:z.literal('manual').optional(),contentOpsEnabled:z.boolean().optional(),schedule:z.object({startAt:z.string().nullable().optional(),endAt:z.string().nullable().optional(),maxCompanies:z.number().int().nullable().optional()}).strict().optional()}).strict();
15
14
  const path=a=>`/workspaces/${a.workspaceId}/campaigns`,detail=a=>path(a)+`/${a.campaignId}`;
16
15
  const qs=a=>new URLSearchParams(Object.fromEntries(Object.entries(a).filter(([k,v])=>!['workspaceId','campaignId'].includes(k)&&v!==undefined).map(([k,v])=>[k,String(v)])));
@@ -24,6 +23,8 @@ export const campaignOperations={
24
23
  campaign_transition:{schema:z.object({...locator,expectedState:state,operation:z.enum(['prepare','activate','pause','resume','close','archive','restore']),reason:z.string().optional()}).strict(),method:'POST',path:a=>detail(a)+'/transitions',body:a=>({expectedState:a.expectedState,operation:a.operation,...(a.reason===undefined?{}:{reason:a.reason})}),scoped:true,description:'Move a Campaign through its lifecycle. prepare: Draft to Preparing (signal tracking and prospect finding run, no engagement). activate: Draft or Preparing to Active (finding and engagement run). pause: stop automation. resume: back to the paused state. close: Active to Closing (finding stops, engagement continues). archive: stop everything and hide it. restore: Archived to Paused, or Draft for a draft. Agents do not start or activate campaigns; the user does. A campaign that cannot run is refused with campaign_not_ready and its missing items in blockers.'},
25
24
  campaign_pace_get:{schema:z.object({...locator}).strict(),method:'GET',path:a=>detail(a)+'/pace',scoped:true,description:'Read a Campaign pace: new people a day. Derived is 25 a day per confirmed LinkedIn account, each capped by its reported connection-request limit; an email-only Campaign uses its own number, 50 by default, within mailbox limits. The reply names the LinkedIn accounts, the weekly figure and the expected daily credits for hosted lookups.'},
26
25
  campaign_pace_set:{schema:z.object({...locator,perDay:z.union([z.number().int().min(1).max(1000),z.literal('derived')]).optional(),maxCredits:z.number().int().min(1).max(100000).nullable().optional()}).strict().refine(a=>a.perDay!==undefined||a.maxCredits!==undefined,{message:'Send perDay, maxCredits or both.'}),method:'POST',path:a=>detail(a)+'/pace',body:a=>({...(a.perDay===undefined?{}:{perDay:a.perDay}),...(a.maxCredits===undefined?{}:{maxCredits:a.maxCredits})}),scoped:true,description:'Save a Campaign pace. Pass the number the user gave, or "derived" to follow the confirmed sending accounts. An owner number stays until changed and is still capped by LinkedIn account and mailbox limits. maxCredits saves the credits a day the user approved for finding (null removes it); each day\'s find task carries it.'},
26
+ campaign_targets_get:{schema:z.object({...locator,limit:z.number().int().min(1).max(500).optional(),after:z.string().regex(/^\d{1,7}$/).optional()}).strict(),method:'GET',path:a=>detail(a)+'/targets?'+qs(a),scoped:true,description:'Read a Campaign\'s target companies in pages of up to 500: domains and LinkedIn company URLs. Only people whose current company is one of them can join.'},
27
+ campaign_targets_change:{schema:z.object({...locator,expectedRevision:z.number().int().positive(),add:z.array(z.string()).optional(),remove:z.array(z.string()).optional(),set:z.array(z.string()).optional()}).strict().refine(a=>(a.set===undefined)!==(a.add===undefined&&a.remove===undefined),{message:'Send set, or add and or remove.'}),method:'POST',path:a=>detail(a)+'/targets',body:a=>({expectedRevision:a.expectedRevision,...Object.fromEntries(['add','remove','set'].filter(k=>a[k]!==undefined).map(k=>[k,a[k]]))}),scoped:true,description:'Change a Campaign\'s target companies: add or remove domains (acme.com) and LinkedIn company URLs, or replace them all with set. Saves a new Campaign revision; an empty set removes the targets.'},
27
28
  campaign_prospect_check:{schema:z.object({...locator,url:z.string().max(1024),requestId:uuid,maxCharge:z.string().regex(/^\d{1,20}$/).optional()}).strict(),method:'POST',path:a=>detail(a)+'/prospect-checks',body:a=>({url:a.url,requestId:a.requestId,...(a.maxCharge!==undefined?{maxCharge:a.maxCharge}:{})}),scoped:true,recoverable:true,timeoutMs:90000,description:'Check one LinkedIn person URL for a Campaign and record the result. Fetches the person and current company (saved profiles are reused free; otherwise each fetch spends hosted credits), runs the pinned Audience rules and records a qualification with the profiles as evidence. outcome fit: admitted, or shortlisted while the Campaign is a Draft. held: an agent must judge a smart rule, a signal or an undecided rule. not_fit: a rule failed. already_found: already in or shortlisted for this Campaign; nothing written. charged and balance are pico USD (1 credit = 10,000,000,000). Generate requestId once and reuse it after an interruption so a retry never pays twice.'},
28
29
  campaign_successor:{schema:z.object({...locator,requestId:uuid,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/successors',body:a=>({requestId:a.requestId,expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
29
30
  };
package/cli/campaigns.mjs CHANGED
@@ -3,7 +3,7 @@ import {readJsonFile} from './json-file.mjs';
3
3
  export async function campaignCli(args){
4
4
  const position=[],flags={};for(let i=0;i<args.length;i++){
5
5
  if(!args[i].startsWith('--')){position.push(args[i]);continue}
6
- const key=args[i].slice(2);if(!['profile','workspace','file','request-id','expected-revision','revision','archived','limit','after','q','state','operation','expected-state','reason','per-day','count','max-credits','url'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error('Unknown or incomplete campaign option.');
6
+ const key=args[i].slice(2);if(!['profile','workspace','file','request-id','expected-revision','revision','archived','limit','after','q','state','operation','expected-state','reason','per-day','count','max-credits','url','add','remove','set'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error('Unknown or incomplete campaign option.');
7
7
  flags[key]=args[++i];
8
8
  }
9
9
  const command=position[1];
@@ -21,8 +21,21 @@ export async function campaignCli(args){
21
21
  const {randomUUID}=await import('node:crypto');
22
22
  console.log(JSON.stringify(await callWorkspace('campaign_prospect_check',{workspaceId:flags.workspace,campaignId:position[2],url:flags.url,requestId:flags['request-id']??randomUUID()},configuration(flags.profile))));return;
23
23
  }
24
+ if(command==='targets'){
25
+ if(position.length!==3)throw new Error('Use campaign targets ID [--add a.com,b.com] [--remove a.com] [--set FILE|-] [--expected-revision N].');
26
+ const config=configuration(flags.profile),input={workspaceId:flags.workspace,campaignId:position[2]};
27
+ const change=['add','remove','set'].some(k=>flags[k]!==undefined);
28
+ if(!change){if(flags.limit!==undefined)input.limit=Number(flags.limit);if(flags.after)input.after=flags.after;console.log(JSON.stringify(await callWorkspace('campaign_targets_get',input,config)));return;}
29
+ if(flags.set!==undefined&&(flags.add!==undefined||flags.remove!==undefined))throw new Error('Use --set on its own, or --add and --remove.');
30
+ const split=v=>v.split(',').map(x=>x.trim()).filter(Boolean);
31
+ if(flags.add!==undefined)input.add=split(flags.add);
32
+ if(flags.remove!==undefined)input.remove=split(flags.remove);
33
+ if(flags.set!==undefined){const {readFileSync}=await import('node:fs');input.set=readFileSync(flags.set==='-'?0:flags.set,'utf8').split(/\r?\n/).map(x=>x.trim()).filter(x=>x&&!x.startsWith('#'));}
34
+ input.expectedRevision=flags['expected-revision']!==undefined?Number(flags['expected-revision']):(await callWorkspace('campaign_targets_get',{workspaceId:flags.workspace,campaignId:position[2],limit:1},config)).data.revision;
35
+ console.log(JSON.stringify(await callWorkspace('campaign_targets_change',input,config)));return;
36
+ }
24
37
  const name={list:'campaign_list',show:'campaign_get',create:'campaign_create',update:'campaign_update',transition:'campaign_transition',successor:'campaign_successor',events:'campaign_events',pace:flags['per-day']===undefined&&flags['max-credits']===undefined?'campaign_pace_get':'campaign_pace_set'}[command];
25
- if(!name||position.length!==(['list','create'].includes(command)?2:3))throw new Error('Use campaign list|show ID|create|update ID|transition ID|successor ID|events ID|pace ID [--per-day N|derived] [--max-credits C|none]|find ID --count N --max-credits C|check ID --url URL.');
38
+ if(!name||position.length!==(['list','create'].includes(command)?2:3))throw new Error('Use campaign list|show ID|create|update ID|transition ID|successor ID|events ID|pace ID [--per-day N|derived] [--max-credits C|none]|find ID --count N --max-credits C|check ID --url URL|targets ID [--add|--remove|--set].');
26
39
  if(flags['per-day']!==undefined&&command!=='pace')throw new Error('Set the pace with campaign pace ID --per-day N|derived, after the campaign is saved.');
27
40
  const input={workspaceId:flags.workspace};
28
41
  if(position[2])input.campaignId=position[2];
@@ -5,7 +5,7 @@ const page=z.object({cursor:uuid.optional(),limit:z.number().int().min(1).max(10
5
5
  const endpoint=z.object({kind:z.enum(['prospect','profile']),id:uuid}).strict();
6
6
  const subject=z.object({kind:z.enum(['person','company']),prospectId:uuid}).strict();
7
7
  const text=z.string().nullable().optional();
8
- const fields=z.object({name:text,title:text,location:text,bio:text,domain:text,description:text,imageUrl:text}).strict();
8
+ const fields=z.object({name:text,title:text,companyName:text,location:text,bio:text,domain:text,description:text,imageUrl:text}).strict();
9
9
  const status=(values)=>z.object({status:z.enum(values),evidenceIds:z.array(uuid)}).strict();
10
10
  const root=a=>`/workspaces/${a.workspaceId}/prospects`;
11
11
  const body=a=>Object.fromEntries(Object.entries(a).filter(([k])=>k!=='workspaceId'));
@@ -6,6 +6,13 @@ const result=z.discriminatedUnion('kind',[
6
6
  z.object({kind:z.literal('linkedin.accept'),person:uuid,at:time}).strict(),
7
7
  z.object({kind:z.literal('linkedin.invite_closed'),person:uuid,reason:z.enum(['withdrawn','expired']),at:time.optional()}).strict(),
8
8
  z.object({kind:z.literal('linkedin.profile_degree'),person:uuid,degree:z.number().int().min(1).max(3),at:time.optional()}).strict(),
9
+ // #413: a steps.run task (a comment, react or withdraw step): the post engaged with, or why the step was skipped.
10
+ z.object({kind:z.literal('linkedin.comment'),person:uuid,postUrl:z.string().url().max(500),at:time.optional()}).strict(),
11
+ z.object({kind:z.literal('linkedin.reaction'),person:uuid,postUrl:z.string().url().max(500),reaction:z.enum(['like','celebrate','support','love','insightful','funny']).optional(),at:time.optional()}).strict(),
12
+ z.object({kind:z.literal('linkedin.follow'),person:uuid,at:time.optional()}).strict(),
13
+ z.object({kind:z.literal('linkedin.unfollow'),person:uuid,at:time.optional()}).strict(),
14
+ z.object({kind:z.literal('linkedin.invitation_received'),ref:z.string().trim().min(1).max(300).optional(),profileUrl:z.string().trim().url().max(500).optional(),name:z.string().trim().min(1).max(200).optional(),sentAt:time.optional()}).strict().refine(value=>!!(value.ref??value.profileUrl),{message:'Give the request id (ref) or the profile address (profileUrl).'}),
15
+ z.object({kind:z.literal('linkedin.step_skipped'),person:uuid,reason:z.string().min(1).max(500),at:time.optional()}).strict(),
9
16
  // #279: a replies.check task (was there a reply to the email).
10
17
  z.object({kind:z.literal('email.no_reply'),person:uuid}).strict(),
11
18
  z.object({kind:z.literal('email.reply'),person:uuid,evidence:uuid}).strict(),
@@ -0,0 +1,62 @@
1
+ // One text for every playbook command, shared by `audienti --help`, `audienti playbook --help`,
2
+ // `audienti playbook SUBCOMMAND --help` and the usage error (#449). The runnable field reference is the
3
+ // playbook-design skill: audienti skills read playbook-design references/fields.md.
4
+ export const playbookCommands = {
5
+ list: {
6
+ usage: 'playbook list [--limit 1..100] [--cursor ID] [--workspace ID] [--profile PATH]',
7
+ about: 'One page of summaries, 50 by default: id, name, currentRevision, system, systemKey, stages, steps, recommended, updatedAt. Starters have system true and cannot be changed; clone one by saving its fields without an id. Use nextCursor as --cursor for the next page. Full fields come from playbook show.'
8
+ },
9
+ show: {
10
+ usage: 'playbook show PLAYBOOK_ID [--revision N] [--workspace ID] [--profile PATH]',
11
+ about: 'The whole playbook (fields) and the campaigns that use it. Without --revision it returns the current one. PLAYBOOK_ID comes from playbook list.'
12
+ },
13
+ save: {
14
+ usage: 'playbook save --file REQUEST.json [--workspace ID] [--profile PATH]',
15
+ about: 'Create a playbook or save a new revision. The file is {"requestId": UUID, "expectedRevision": N, "id": PLAYBOOK_ID, "fields": {...}}. requestId: a fresh UUID; reuse it only to retry the same save. id: leave it out for a new playbook. expectedRevision: 0 for a new playbook, otherwise currentRevision from playbook show. A save from a stale revision is refused with revision_conflict and the response gives currentRevision. fields: name, stages (2 or more, each with a UUID key), goalKey (a stage key after the first stage), offRamps. Field rules: audienti skills read playbook-design references/fields.md',
16
+ example: {
17
+ requestId: 'UUID',
18
+ expectedRevision: 0,
19
+ fields: {
20
+ name: 'Email only',
21
+ stages: [
22
+ { key: 'STAGE_A_UUID', name: 'Ready', owner: 'Agent', goalDays: 3, steps: [{ key: 'UUID', channel: 'Email', action: 'Email 1', condition: 'Always', instructions: 'Three sentences.', wait: { until: 'reply', days: 4 } }], completion: { kind: 'next' } },
23
+ { key: 'STAGE_B_UUID', name: 'Meeting booked', on: ['meeting'], owner: 'Assignee', goalDays: 0, steps: [], completion: { kind: 'stay' } }
24
+ ],
25
+ goalKey: 'STAGE_B_UUID',
26
+ offRamps: [{ key: 'UUID', name: 'Unresponsive', reason: 'unresponsive' }]
27
+ }
28
+ }
29
+ },
30
+ progress: {
31
+ usage: 'playbook progress PARTICIPATION_ID [--workspace ID] [--profile PATH]',
32
+ about: 'The stage and outcome history of one person in one campaign. It takes a participation ID, not a playbook ID. A participation exists once a person is admitted to a campaign. Each person in the output of pipeline show has a participationId.'
33
+ },
34
+ record: {
35
+ usage: 'playbook record --file REQUEST.json [--workspace ID] [--profile PATH]',
36
+ about: 'Report that a person moved to a stage or off-ramp, or finished the current stage play. The file has participationId, expectedVersion (the version from playbook progress), requestId (a fresh UUID), exactly one of stageKey, offRampKey or finishStage true, reason, and evidence (a list of {kind: observation|conversation, id}).'
37
+ },
38
+ suggest: {
39
+ usage: 'playbook suggest --file REQUEST.json [--workspace ID] [--profile PATH]',
40
+ about: 'The same request as record, but it asks a person to approve the move in Operator. Nothing changes until they approve.'
41
+ },
42
+ suggestions: {
43
+ usage: 'playbook suggestions [--limit 1..100] [--cursor ID] [--workspace ID] [--profile PATH]',
44
+ about: 'Pending suggestions that wait for a person to approve or dismiss them.'
45
+ },
46
+ decide: {
47
+ usage: 'playbook decide --file REQUEST.json [--workspace ID] [--profile PATH]',
48
+ about: 'Approve or dismiss one pending suggestion: {"id": SUGGESTION_ID, "requestId": UUID, "decision": "approve" or "dismiss"}. People only; an agent cannot approve.'
49
+ }
50
+ };
51
+ const lines = Object.values(playbookCommands).map(command => command.usage);
52
+ export const playbookUsageError = `Unknown or missing playbook subcommand. Usage:\n${lines.map(line => ` audienti ${line}`).join('\n')}\nRun audienti playbook SUBCOMMAND --help for the request shape.`;
53
+ /** Help for `audienti playbook [SUBCOMMAND] --help`. */
54
+ export function playbookHelp(subcommand) {
55
+ const names = playbookCommands[subcommand] ? [subcommand] : Object.keys(playbookCommands);
56
+ const text = names.map(name => {
57
+ const command = playbookCommands[name];
58
+ return `audienti ${command.usage}\n ${command.about}${command.example ? `\n\nExample REQUEST.json (replace each UUID with a fresh one; reuse the same UUID where a key repeats):\n${JSON.stringify(command.example, null, 2)}` : ''}`;
59
+ }).join('\n\n');
60
+ const conflict = names.includes('save') ? ' A revision_conflict from save also gives currentRevision.' : '';
61
+ return `${text}\n\nErrors are JSON on stderr: {"error": {"code", "message"}}, and the exit code is 1.${conflict} Errors from the server also give status.`;
62
+ }
@@ -3,19 +3,25 @@ const uuid=z.string().uuid(),scope={workspaceId:uuid.optional()};
3
3
  const label=z.string().trim().min(1).max(120).refine(value=>!/[\u0000-\u001f\u007f]/.test(value),'Control characters are not allowed.');
4
4
  const text=z.string().trim().max(5000);
5
5
  const identity=z.object({key:z.string().uuid(),name:label}).strict();
6
- const playbookStepSchema=z.object({key:z.string().uuid(),channel:z.enum(['LinkedIn','Email','Phone']),action:label,condition:z.enum(['Always','Only if viable','Only if connected']),viability:text.optional(),instructions:text.optional(),wait:z.object({until:z.enum(['accept','reply','meeting','timeout']),days:z.number().int().min(1).max(60)}).strict().optional(),waitDays:z.number().int().min(1).max(60).optional().describe('Older form of wait: the same as wait {until: timeout, days}.'),side:z.boolean().optional(),onTimeout:z.enum(['next','off_ramp']).optional()}).strict();
6
+ const routeTarget=z.discriminatedUnion('kind',[z.object({kind:z.literal('stage'),stageKey:z.string().uuid()}).strict(),z.object({kind:z.literal('off_ramp'),offRampKey:z.string().uuid()}).strict(),z.object({kind:z.literal('step'),stepKey:z.string().uuid()}).strict()]);
7
+ // An event rule (#350 T3): what happens when an event reaches a person. The server checks the event and action ids against the outreach rulebook.
8
+ const eventRule=z.object({key:z.string().uuid(),when:z.string().min(1).max(60).describe('An event id from the outreach rulebook, for example accepted, replied, wait_timeout or reply.out_of_office.'),do:z.array(z.enum(['stop_wait','snooze','redirect_to_named_person','ask_who_is_right','tell_me','hand_to_user','withdraw_pending_request'])).max(7).optional(),snoozeDays:z.number().int().min(1).max(90).optional(),goto:z.discriminatedUnion('kind',[z.object({kind:z.literal('stay')}).strict(),z.object({kind:z.literal('gate')}).strict(),...routeTarget.options]).optional()}).strict();
9
+ const stepRoutes=z.object({accept:routeTarget.optional(),reply:routeTarget.optional(),meeting:routeTarget.optional(),timeout:routeTarget.optional()}).strict().describe("Where an event that reaches the person after this step ran sends them. A route wins over the stage's jump list; an event with no route follows the stage. Going to an earlier stage needs rules.allowBackward.");
10
+ const playbookStepSchema=z.object({key:z.string().uuid(),channel:z.enum(['LinkedIn','Email']),action:label,condition:z.enum(['Always','Only if viable','Only if connected']),viability:text.optional(),instructions:text.optional(),wait:z.object({until:z.enum(['accept','reply','meeting','timeout']),days:z.number().int().min(1).max(60),followUps:z.number().int().min(0).max(10).optional().describe('Meeting waits only: how many follow-ups your agent writes, one after each wait, before the meeting is marked No answer. Default 3.')}).strict().optional(),waitDays:z.number().int().min(1).max(60).optional().describe('Older form of wait: the same as wait {until: timeout, days}.'),side:z.boolean().optional(),onTimeout:z.enum(['next','off_ramp']).optional(),humanize:z.boolean().optional().describe('Email only. Rewrite the email text before approval so it reads as written by a person. On by default; set false to send the text as written.'),actionId:z.string().max(80).optional().describe('An action id from the outreach rulebook, for example linkedin.message.send.'),routes:stepRoutes.optional(),eventRules:z.array(eventRule).max(10).optional()}).strict();
7
11
  const completion=z.discriminatedUnion('kind',[z.object({kind:z.literal('next')}).strict(),z.object({kind:z.literal('stay')}).strict(),z.object({kind:z.literal('off_ramp'),offRampKey:z.string().uuid()}).strict()]);
8
- const stage=identity.extend({movesInWhen:text.optional(),owner:z.enum(['Agent','Assignee']).optional(),goalDays:z.number().int().min(0).max(365).optional(),optional:z.boolean().optional(),enabled:z.boolean().optional(),steps:z.array(playbookStepSchema).max(20).optional(),completion:completion.optional(),on:z.array(z.enum(['accept','reply','meeting','manual'])).max(4).optional()});
9
- const playbookRulesSchema=z.object({autoMove:z.boolean().optional(),flagOverdue:z.boolean().optional(),allowBackward:z.boolean().optional(),emailWindow:z.enum(['weekdays_morning','weekdays','any_day']).optional(),nurtureDays:z.union([z.literal(60),z.literal(90),z.literal(180),z.null()]).optional()}).strict();
10
- const playbookFieldsSchema=z.object({name:label,stages:z.array(stage).min(2).max(30),goalKey:z.string().uuid(),offRamps:z.array(identity.extend({reason:z.enum(['rejected','not_fit','nurture','unresponsive','other','taken_over','bounced','skipped'])})).max(30),voice:text.optional(),rules:playbookRulesSchema.optional()}).strict();
12
+ const stage=identity.extend({movesInWhen:text.optional(),owner:z.enum(['Agent','Assignee']).optional(),goalDays:z.number().int().min(0).max(365).optional(),optional:z.boolean().optional(),enabled:z.boolean().optional(),steps:z.array(playbookStepSchema).max(20).optional(),completion:completion.optional(),on:z.array(z.enum(['accept','reply','meeting','manual'])).max(4).optional(),eventRules:z.array(eventRule).max(10).optional()});
13
+ // A playbook saved before conflict 20 may carry autoMove; it is dropped, because approval lives on the account.
14
+ const dropAutoMove=value=>{if(!value||typeof value!=='object'||Array.isArray(value)||!('autoMove' in value))return value;const {autoMove:_dropped,...rest}=value;return rest;};
15
+ const playbookRulesSchema=z.preprocess(dropAutoMove,z.object({flagOverdue:z.boolean().optional(),allowBackward:z.boolean().optional(),emailWindow:z.enum(['weekdays_morning','weekdays','any_day']).optional(),nurtureDays:z.union([z.literal(60),z.literal(90),z.literal(180),z.null()]).optional()}).strict());
16
+ const playbookFieldsSchema=z.object({name:label,stages:z.array(stage).min(2).max(30),goalKey:z.string().uuid(),offRamps:z.array(identity.extend({reason:z.enum(['rejected','not_fit','nurture','unresponsive','other','taken_over','bounced','skipped'])})).max(30),voice:text.optional(),rules:playbookRulesSchema.optional(),eventRules:z.array(eventRule).max(30).optional()}).strict();
11
17
  const fields=playbookFieldsSchema;
12
18
  const base=a=>`/workspaces/${a.workspaceId}`,query=(a,skip=[])=>new URLSearchParams(Object.entries(a).filter(([k,v])=>k!=='workspaceId'&&!skip.includes(k)&&v!==undefined).map(([k,v])=>[k,String(v)]));
13
19
  export const playbookOperations={
14
- playbook_list:{schema:z.object({...scope,cursor:uuid.optional(),limit:z.number().int().min(1).max(100).optional()}).strict(),method:'GET',path:a=>`${base(a)}/playbooks?${query(a)}`,scoped:true,description:'List immutable versioned playbooks in the current workspace. Starter playbooks (Email first, LinkedIn first, Email only) carry system:true and are read-only; clone one by saving its fields without an id. recommended marks the starter playbook that fits the channels your agents reported, and recommendation gives its reason in one line. Free CRM read.'},
20
+ playbook_list:{schema:z.object({...scope,cursor:uuid.optional().describe('The nextCursor of the previous page.'),limit:z.number().int().min(1).max(100).optional().describe('Playbooks per page. Default 50.')}).strict(),method:'GET',path:a=>`${base(a)}/playbooks?${query(a)}`,scoped:true,description:'List the playbooks in the current workspace, one page of summaries (id, name, currentRevision, system, systemKey, stages, steps, recommended, updatedAt) with nextCursor. Read the fields of one with playbook_get. Starter playbooks (Email first, LinkedIn first, Email only) carry system:true and are read-only; clone one by saving its fields without an id. recommended marks the starter playbook that fits the channels your agents reported, and recommendation gives its reason in one line. Free CRM read.'},
15
21
  playbook_get:{schema:z.object({...scope,id:uuid,revision:z.number().int().positive().optional()}).strict(),method:'GET',path:a=>`${base(a)}/playbooks/${a.id}?${query(a,['id'])}`,scoped:true,description:'Read a playbook version: stage plays, step waits, completion, goal, voice and agent rules. Instructions are executed by the external agent, never by a hosted sequence runner.'},
16
- playbook_save:{schema:z.object({...scope,id:uuid.optional(),requestId:uuid,expectedRevision:z.number().int().nonnegative(),fields}).strict(),method:'POST',path:a=>`${base(a)}/playbooks`,body:({workspaceId,...a})=>a,scoped:true,description:'Create or revise a playbook with optimistic version and idempotent request. A starter playbook refuses revision (409 starter_playbook); save its fields without an id to make an editable copy. Does not change existing campaign or participation pins.'},
22
+ playbook_save:{schema:z.object({...scope,id:uuid.optional(),requestId:uuid,expectedRevision:z.number().int().nonnegative().describe('0 for a new playbook, otherwise currentRevision from playbook_get. A stale value is refused with revision_conflict, which carries currentRevision.'),fields}).strict(),method:'POST',path:a=>`${base(a)}/playbooks`,body:({workspaceId,...a})=>a,scoped:true,description:'Create or revise a playbook with optimistic version and idempotent request. A starter playbook refuses revision (409 starter_playbook); save its fields without an id to make an editable copy. Does not change existing campaign or participation pins.'},
17
23
  workspace_pipeline_get:{schema:z.object({...scope,campaignId:uuid.optional(),pinId:uuid.optional()}).strict(),method:'GET',path:a=>`${base(a)}/pipeline?${query(a)}`,scoped:true,description:'Read the pipeline board: stages of one playbook version, the people currently in each stage, and counts of people closed out through each off-ramp. Free CRM read.'},
18
- playbook_progress_get:{schema:z.object({...scope,participationId:uuid}).strict(),method:'GET',path:a=>`${base(a)}/participations/${a.participationId}/progress`,scoped:true,description:'Read the participation’s admitted playbook pin and recorded stage/outcome history.'},
24
+ playbook_progress_get:{schema:z.object({...scope,participationId:uuid.describe('A participation ID (one person in one campaign, from the pipeline), not a playbook ID.')}).strict(),method:'GET',path:a=>`${base(a)}/participations/${a.participationId}/progress`,scoped:true,description:'Read the participation’s admitted playbook pin and recorded stage/outcome history. Takes a participation ID (a person in a campaign; each person in the pipeline has one), not a playbook ID.'},
19
25
  playbook_progress_record:{schema:z.object({...scope,participationId:uuid,expectedVersion:z.number().int().nonnegative(),requestId:uuid,stageKey:uuid.optional(),offRampKey:uuid.optional(),finishStage:z.literal(true).optional(),reason:z.string().trim().min(1).max(2000),evidence:z.array(z.object({kind:z.enum(['observation','conversation']),id:uuid}).strict()).max(20)}).strict(),method:'POST',path:a=>`${base(a)}/participations/${a.participationId}/progress`,body:({workspaceId,participationId,...a})=>a,scoped:true,description:'Report a stage, off-ramp, or finishStage:true after externally completing the current play and waits. Finish resolves next/stay/off-ramp from the admitted version. Stored evidence required for agents; human-review and backward-move rules apply. Assigned owner/Admin only. Goal ends prospecting; post-goal forward reporting never reactivates. No send or provider call.'},
20
26
  playbook_progress_suggest:{schema:z.object({...scope,participationId:uuid,expectedVersion:z.number().int().nonnegative(),requestId:uuid,stageKey:uuid.optional(),offRampKey:uuid.optional(),finishStage:z.literal(true).optional(),reason:z.string().trim().min(1).max(2000),evidence:z.array(z.object({kind:z.enum(['observation','conversation']),id:uuid}).strict()).max(20)}).strict(),method:'POST',path:a=>`${base(a)}/participations/${a.participationId}/suggestions`,body:({workspaceId,participationId,...a})=>a,scoped:true,description:'Suggest a pinned stage move, off-ramp or play completion for human review in Operator. Stored evidence required. Does not change progress or send anything. Assigned owner/Admin; current human approval revalidates progress.'},
21
27
  playbook_suggestion_decide:{schema:z.object({...scope,id:uuid,requestId:uuid,decision:z.enum(['approve','dismiss'])}).strict(),method:'POST',path:a=>`${base(a)}/playbook-suggestions/${a.id}/decision`,body:({workspaceId,id,...a})=>a,scoped:true,description:'Approve or dismiss a pending stage suggestion. People only: the current owner or an Admin. Approving moves the participation to the suggested stage.'},
package/cli/progress.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import {callWorkspace,configuration} from './workspaces.mjs';
2
2
  import {readJsonFile} from './json-file.mjs';
3
+ import {playbookUsageError,playbookCommands} from './playbook-help.mjs';
3
4
  export async function progressCli(args){
4
- const positions=[],flags={};for(let i=0;i<args.length;i++){const arg=args[i];if(!arg.startsWith('--')){positions.push(arg);continue;}const key=arg.slice(2);if(!['profile','workspace','file','cursor','limit','revision','from','to','campaign','playbook-version','kind','user','format','owner','offer','audience','tag'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error('Unknown or incomplete option.');flags[key]=args[++i];}
5
+ const positions=[],flags={};for(let i=0;i<args.length;i++){const arg=args[i];if(!arg.startsWith('--')){positions.push(arg);continue;}const key=arg.slice(2);if(!['profile','workspace','file','cursor','limit','revision','from','to','campaign','playbook-version','kind','user','format','owner','offer','audience','tag'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error(`Unknown or incomplete option --${key}.${args[0]==='playbook'?' Run audienti playbook SUBCOMMAND --help.':''}`);flags[key]=args[++i];}
5
6
  const names={playbook:{list:'playbook_list',show:'playbook_get',save:'playbook_save',progress:'playbook_progress_get',record:'playbook_progress_record',suggest:'playbook_progress_suggest',suggestions:'playbook_suggestions_list',decide:'playbook_suggestion_decide'},analytics:{show:'workspace_analytics_get'},pipeline:{show:'workspace_pipeline_get'},connections:{list:'workspace_connections_list',register:'workspace_connection_register',report:'workspace_connection_report',disconnect:'workspace_connection_disconnect'}};
6
- const name=names[positions[0]]?.[positions[1]];if(!name)throw new Error('Use playbook list|show ID|save --file PATH|progress ID|record|suggest --file PATH|suggestions|decide --file PATH; analytics show --from ISO --to ISO; pipeline show; connections list|register --file PATH|report --file PATH|disconnect --file PATH.');
7
+ const name=names[positions[0]]?.[positions[1]];if(!name)throw new Error(positions[0]==='playbook'?playbookUsageError:'Use analytics show --from ISO --to ISO; pipeline show; connections list|register --file PATH|report --file PATH|disconnect --file PATH.');
7
8
  if(flags.format&&(name!=='workspace_analytics_get'||!['json','csv'].includes(flags.format)))throw new Error('--format json|csv is only accepted for analytics show.');
8
- const fileNames=['playbook_save','playbook_progress_record','playbook_progress_suggest','workspace_connection_register','workspace_connection_report','playbook_suggestion_decide','workspace_connection_disconnect'];const withId=['playbook_get','playbook_progress_get'].includes(name);if(positions.length!==(withId?3:2))throw new Error('Unexpected arguments.');
9
+ const fileNames=['playbook_save','playbook_progress_record','playbook_progress_suggest','workspace_connection_register','workspace_connection_report','playbook_suggestion_decide','workspace_connection_disconnect'];const withId=['playbook_get','playbook_progress_get'].includes(name);if(positions.length!==(withId?3:2))throw new Error(positions[0]==='playbook'?`Unexpected arguments. Usage: audienti ${playbookCommands[positions[1]].usage}`:'Unexpected arguments.');
9
10
  if(fileNames.includes(name)&&!flags.file)throw new Error('Supply a JSON request with --file PATH.');if(!fileNames.includes(name)&&flags.file)throw new Error('--file is only accepted for mutations.');
10
11
  const input=fileNames.includes(name)?readJsonFile(flags.file,65536,'Request file'):{};if(!input||typeof input!=='object'||Array.isArray(input))throw new Error('Request must be an object.');if(flags.workspace)input.workspaceId=flags.workspace;if(withId)input[name==='playbook_get'?'id':'participationId']=positions[2];
11
12
  for(const[key,value]of Object.entries(flags)){if(['workspace','profile','file','format'].includes(key))continue;const field={campaign:'campaignId','playbook-version':'pinId',user:'userId',owner:'ownerUserId',offer:'offerId',audience:'audienceId'}[key]??key;input[field]=['limit','revision'].includes(key)?Number(value):value;}
@@ -14,7 +15,7 @@ export async function progressCli(args){
14
15
  console.log(JSON.stringify(result));
15
16
  }
16
17
 
17
- /** Same columns as Export CSV on the Analytics page. Untracked outcome values are left blank. */
18
+ /** Daily analytics rows plus a total row. Untracked outcome values are left blank. */
18
19
  export function analyticsCsv(data){
19
20
  if(!data||!Array.isArray(data.daily)||!data.total)throw new Error('Analytics response has no daily rows.');
20
21
  const cell=value=>{const text=String(value);return /[",\n]/.test(text)?`"${text.replace(/"/g,'""')}"`:text;};
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { callWorkspace, configuration } from './workspaces.mjs';
3
3
  export async function recipientsCli(args) {
4
4
  const flags = {}, positions = [];
5
- const allowed = ['workspace','profile','status','cursor','limit','history-cursor','history-limit','file'];
5
+ const allowed = ['workspace','profile','status','cursor','limit','history-cursor','history-limit','file','request-id'];
6
6
  for (let i=0;i<args.length;i++) {
7
7
  const arg=args[i]; if(arg==='--json')continue;
8
8
  if(!arg.startsWith('--')){positions.push(arg);continue;}
@@ -12,7 +12,7 @@ export async function recipientsCli(args) {
12
12
  }
13
13
  const command=positions[2];
14
14
  if(positions[0]!=='prospects'||positions[1]!=='contacts'||!['list','get','record','retire'].includes(command)||positions.length!==(['get','retire'].includes(command)?5:4))throw Error('Use prospects contacts list|record PERSON or get|retire PERSON CONTACT --workspace ID.');
15
- const valid=['workspace','profile',...(command==='list'?['status','cursor','limit']:command==='get'?['history-cursor','history-limit']:['file'])];
15
+ const valid=['workspace','profile',...(command==='list'?['status','cursor','limit']:command==='get'?['history-cursor','history-limit']:['file','request-id'])];
16
16
  if(Object.keys(flags).some(k=>!valid.includes(k)))throw Error('Option does not apply to this contact command.');
17
17
  let input={};
18
18
  if(['record','retire'].includes(command)){
@@ -21,6 +21,10 @@ export async function recipientsCli(args) {
21
21
  try{input=JSON.parse(raw.toString('utf8'));}catch{throw Error('validation_error');}
22
22
  if(!input||typeof input!=='object'||Array.isArray(input)||['workspaceId','prospectId','contactId'].some(k=>Object.hasOwn(input,k)))throw Error('invalid_fields');
23
23
  }
24
+ if(flags['request-id']!==undefined){
25
+ if(input.requestId!==undefined&&input.requestId!==flags['request-id'])throw Error('--request-id differs from requestId in the file.');
26
+ input.requestId=flags['request-id'];
27
+ }
24
28
  Object.assign(input,{workspaceId:flags.workspace,prospectId:positions[3]});
25
29
  if(['get','retire'].includes(command))input.contactId=positions[4];
26
30
  if(command==='list')Object.assign(input,{...(flags.status===undefined?{}:{status:flags.status}),...(flags.cursor===undefined?{}:{cursor:flags.cursor}),...(flags.limit===undefined?{}:{limit:Number(flags.limit)})});
@@ -17,7 +17,7 @@ export const roomsOperations={
17
17
  rooms_list:{schema:z.object({workspaceId,retired:z.boolean().optional(),q:z.string().max(80).optional()}).strict(),method:'GET',path:a=>`${base(a)}${query(a,['retired','q'])}`,scoped:true,description:`List active or retired rooms; q keeps rooms with a message or record label containing the text. ${description}`},
18
18
  rooms_read:{schema:z.object({workspaceId,room:z.string().min(1).max(64),q:z.string().max(80).optional()}).strict(),method:'GET',path:a=>`${base(a)}/${encodeURIComponent(a.room)}${query(a,['q'])}`,scoped:true,description:`Read one whole room: its newest threads, each with its latest messages, deliveries and open questions; q keeps only threads with a matching message. ${owner} ${description}`},
19
19
  rooms_create:{schema:z.object({workspaceId,kind:z.enum(['direct','group']),with:handle.optional(),slug:handle.optional(),title:z.string().max(80).optional(),purpose:z.string().max(280).optional(),members:z.array(handle).max(20).optional()}).strict(),method:'POST',path:base,body:body(['kind','with','slug','title','purpose','members']),scoped:true,description:'Open a direct room with another of your agents (with HANDLE) or create a group room (slug, title, purpose, members).'},
20
- rooms_send:{schema:z.object({workspaceId,room:z.string().min(1).max(64).optional(),to:handle.optional(),threadId:uuid.optional(),body:z.string().min(1).max(8000),about:z.array(reference).max(10).optional(),answerBy:z.string().datetime().optional(),expiresAt:z.string().datetime().optional(),unable:z.boolean().optional(),requestId:uuid}).strict(),method:'POST',path:a=>`${base(a)}/messages`,body:body(['room','to','threadId','body','about','answerBy','expiresAt','unable','requestId']),scoped:true,description:`Send a message to a room (room) or to another of your agents (to HANDLE). Pass threadId to reply. Set unable with threadId to say you cannot do what the thread asks; body is the reason and your pending items in the thread are acknowledged. Reuse requestId when retrying. Limits: 30 per 10 minutes, 300 per day, 8,000 characters. Sending grants nothing; approvals stay with the user. ${description}`},
20
+ rooms_send:{schema:z.object({workspaceId,room:z.string().min(1).max(64).optional(),to:handle.optional(),threadId:uuid.optional(),body:z.string().min(1).max(8000),about:z.array(reference).max(10).optional(),answerBy:z.string().datetime().optional(),ifNoAnswer:z.string().min(1).max(1000).optional(),anyOne:z.boolean().optional(),expiresAt:z.string().datetime().optional(),unable:z.boolean().optional(),requestId:uuid}).strict(),method:'POST',path:a=>`${base(a)}/messages`,body:body(['room','to','threadId','body','about','answerBy','ifNoAnswer','anyOne','expiresAt','unable','requestId']),scoped:true,description:`Send a message to a room (room) or to another of your agents (to HANDLE). Pass threadId to reply. Set unable with threadId to say you cannot do what the thread asks; body is the reason and your pending items in the thread are acknowledged. Reuse requestId when retrying. Limits: 30 per 10 minutes, 300 per day, 8,000 characters. Set answerBy for a deadline; with ifNoAnswer your stated fallback is recorded as your own open commitment when no one answers (Exora performs nothing). In a channel, or a direct message with more than two agents, a deadline question names one agent with @handle or sets anyOne so the first to claim it owns it. Sending grants nothing; approvals stay with the user. ${description}`},
21
21
  rooms_thread:{schema:z.object({workspaceId,threadId:uuid,after:z.number().int().min(0).optional()}).strict(),method:'GET',path:a=>`${base(a)}/threads/${a.threadId}${query(a,['after'])}`,scoped:true,description:`Read a thread in order. Each message lists deliveries per recipient: pending (probablySeen when the recipient checked its inbox since), acknowledged or expired. ${description}`},
22
22
  rooms_threads:{schema:z.object({workspaceId,about}).strict(),method:'GET',path:a=>`${base(a)}/threads${query(a,['about'])}`,scoped:true,description:'List threads that reference a record, for example prospect:UUID.'},
23
23
  rooms_retire:{schema:z.object({workspaceId,room:z.string().min(1).max(64)}).strict(),method:'POST',path:a=>`${base(a)}/${encodeURIComponent(a.room)}/retire`,scoped:true,description:'Retire a finished room. Pending items expire; history stays.'},
@@ -26,3 +26,23 @@ export const roomsOperations={
26
26
  rooms_resume:{schema:z.object({workspaceId,threadId:uuid}).strict(),method:'POST',path:a=>`${base(a)}/threads/${a.threadId}/resume`,scoped:true,description:`Resume a thread paused after 30 agent-only messages. ${owner}`},
27
27
  rooms_pause:{schema:z.object({workspaceId,paused:z.boolean()}).strict(),method:'POST',path:a=>`${base(a)}/pause`,body:body(['paused']),scoped:true,description:'Pause or resume all agent sending in the workspace. Workspace Admin user credential only.'},
28
28
  };
29
+
30
+ const text=n=>z.string().min(1).max(n),time=z.string().datetime(),place={room:z.string().min(1).max(64).optional(),threadId:uuid.optional()};
31
+ const coord='A coordination record in a thread: a message plus a structured record. It informs and records; Exora performs nothing, and a message is never an approval.';
32
+ const aboutList=z.array(reference).max(10).optional();
33
+ const note=z.object({goal:text(1000),state:text(1000),nextAction:text(1000),openItems:z.array(text(300)).max(10).optional(),risks:z.array(text(300)).max(10).optional()}).strict();
34
+ const post=(path,keys)=>({method:'POST',path,body:body(keys),scoped:true});
35
+ Object.assign(roomsOperations,{
36
+ rooms_decision_request:{schema:z.object({workspaceId,...place,question:text(1000),options:z.array(text(200)).min(2).max(6),deciders:z.array(handle).min(1).max(8),rule:z.enum(['one','any','all','majority']),deadline:time,defaultOption:text(200).optional(),ifNoAnswer:text(1000).optional(),supersedes:uuid.optional(),about:aboutList,requestId:uuid}).strict(),...post(a=>`${base(a)}/decisions`,['room','threadId','question','options','deciders','rule','deadline','defaultOption','ifNoAnswer','supersedes','about','requestId']),description:`Ask named agents (deciders) to choose between 2 to 6 options by a deadline. rule: one or any decider, all, or majority. The request expires at its deadline; a named defaultOption then applies and shows as "Default applied. No one responded", never as agreement, and ifNoAnswer is recorded as your own open commitment. Never name a person: ask your owner with rooms_ask_owner. ${coord}`},
37
+ rooms_decision_respond:{schema:z.object({workspaceId,decisionId:uuid,stance:z.enum(['support','object','abstain']),option:text(200).optional(),reason:text(2000),requestId:uuid}).strict(),...post(a=>`${base(a)}/decisions/${a.decisionId}/respond`,['stance','option','reason','requestId']),description:'Answer a decision request you are named on: support one option, object, or abstain, with a reason. A response cannot be changed. When the rule is met Exora records the Decision and tells the requester and the deciders.'},
38
+ rooms_decision_withdraw:{schema:z.object({workspaceId,decisionId:uuid}).strict(),...post(a=>`${base(a)}/decisions/${a.decisionId}/withdraw`,[]),description:'Withdraw your own open decision request.'},
39
+ rooms_commit:{schema:z.object({workspaceId,...place,summary:text(1000),dueAt:time.optional(),for:handle.optional(),about:aboutList,requestId:uuid}).strict(),...post(a=>`${base(a)}/commitments`,['room','threadId','summary','dueAt','for','about','requestId']),description:`Record what you will do, with an optional due time and the agent it is for. A commitment is a statement, not a lock: it blocks nothing and does not touch work claims. ${coord}`},
40
+ rooms_commit_update:{schema:z.object({workspaceId,commitmentId:uuid,status:z.enum(['done','released']),note:text(1000).optional(),requestId:uuid}).strict(),...post(a=>`${base(a)}/commitments/${a.commitmentId}/update`,['status','note','requestId']),description:'Mark your own commitment done, or released when you will not do it.'},
41
+ rooms_handoff:{schema:z.object({workspaceId,commitmentId:uuid,to:handle.optional(),anyOne:z.boolean().optional(),note,answerBy:time.optional(),about:aboutList,requestId:uuid}).strict(),...post(a=>`${base(a)}/commitments/${a.commitmentId}/handoff`,['to','anyOne','note','answerBy','about','requestId']),description:`Offer your open commitment to one agent (to), or to any one agent in a channel (anyOne), with a turnover note (goal, state, nextAction). You stay the owner until someone accepts; a decline or a lapsed deadline changes nothing. ${coord}`},
42
+ rooms_handoff_reply:{schema:z.object({workspaceId,commitmentId:uuid,action:z.enum(['accept','decline']),note:text(1000).optional(),requestId:uuid}).strict(),...post(a=>`${base(a)}/commitments/${a.commitmentId}/handoff-reply`,['action','note','requestId']),description:'Accept or decline a commitment offered to you. Accepting moves the owner and keeps the turnover note on the record.'},
43
+ rooms_claim:{schema:z.object({workspaceId,messageId:uuid,requestId:uuid}).strict(),...post(a=>`${base(a)}/messages/${a.messageId}/claim`,['requestId']),description:'Take an "anyOne" ask: the first claim wins; a later claim is told who has it and can still reply. Claiming an anyOne handoff accepts it. A claim blocks nothing and does not touch work claims.'},
44
+ rooms_ask_owner:{schema:z.object({workspaceId,...place,questionId:uuid,answerBy:time.optional(),ifNoAnswer:text(1000).optional(),requestId:uuid}).strict(),...post(a=>`${base(a)}/owner-questions`,['room','threadId','questionId','answerBy','ifNoAnswer','requestId']),description:'Show in a thread that you asked your owner a question in Operator (questionId is that question). The room shows its state and a link and offers no way to answer. The person answers in Operator and the answer returns through your queue. Silence is never an answer, a default or an approval.'},
45
+ rooms_owner_create:{schema:z.object({workspaceId,kind:z.enum(['direct','group']),name:z.string().max(80).optional(),purpose:z.string().max(280).optional(),members:z.array(handle).min(1).max(20)}).strict(),...post(a=>`${base(a)}/owner-rooms`,['kind','name','purpose','members']),description:`Start a channel (kind group, with a name) or a direct message among two or more of your agents (kind direct). ${owner}`},
46
+ rooms_owner_post:{schema:z.object({workspaceId,room:z.string().min(1).max(64),threadId:uuid.optional(),body:z.string().min(1).max(8000),requestId:uuid}).strict(),...post(a=>`${base(a)}/owner-messages`,['room','threadId','body','requestId']),description:`Post a plain message labeled Owner in a room. It is information for the agents: never an approval and never a decision. A person's decision goes through an Operator question. ${owner}`},
47
+ rooms_member_add:{schema:z.object({workspaceId,room:z.string().min(1).max(64),agentId:uuid}).strict(),...post(a=>`${base(a)}/${encodeURIComponent(a.room)}/members`,['agentId']),description:`Add one of your agents to a channel. A direct message keeps its agents. ${owner}`},
48
+ });