@audienti/cli 0.2.0-exora.6 → 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
@@ -50,6 +51,7 @@ Audiences: audience options --kind industries|locations|sizes|types|seniorities|
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
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
@@ -80,7 +82,7 @@ Find prospects: campaign find CAMPAIGN_ID --count N --max-credits C (searches w
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
83
  Target companies: campaign targets CAMPAIGN_ID [--limit N --after CURSOR] (reads the companies people must work at to join)
82
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)
83
- Playbooks: playbook list|show ID|save --file REQUEST.json|progress ID|record --file REQUEST.json
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)
84
86
  Analytics: analytics show --from ISO --to ISO [--campaign ID] [--playbook-version ID] [--format json|csv]
85
87
  analytics outbound [--workspace ID] [--days N | --from ISO --to ISO] [--by sender|action|day] [--format json|csv]
86
88
  analytics activity [--workspace ID] [--days N | --from ISO --to ISO] [--format json|csv]
@@ -97,7 +99,7 @@ Read state: workspace notification-read --workspace ID --key KEY --read true|fal
97
99
  Search: workspace search --workspace ID --query TEXT [--people-only true] [--recent FILE.json]
98
100
  Invitation commands: workspace invitation show|accept|decline|retry|cancel ID
99
101
  Options: --workspace ID --name NAME --request-id UUID --email EMAIL --generation N
100
- Optional private profile: --profile PATH (outside this repository)
102
+ Optional private profile: --profile PATH (outside this repository), or the AUDIENTI_PROFILE variable. --profile wins.
101
103
  Save an environment token: profile save --scope account:read|workspace:access|agents:manage --profile PATH
102
104
  Start local MCP stdio server: mcp
103
105
  --json writes the API response envelope.
@@ -126,6 +128,7 @@ function commandHelp() {
126
128
  }
127
129
 
128
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; }
129
132
  if (args.length > 1 && args.slice(1).some(value => value === '--help' || value === '-h') && !['tools', 'call', 'skills'].includes(args[0])) { commandHelp(); return; }
130
133
  if(args[0]==='prospects'&&['intake','conversation'].includes(args[1])){const {accountIntakeCli}=await import('./account-intake.mjs');await accountIntakeCli(args);return;}
131
134
  if(args[0]==='sender'){const {senderAccountsCli}=await import('./sender-accounts.mjs');await senderAccountsCli(args);return;}
@@ -154,6 +157,7 @@ async function main() {
154
157
  if(args[0]==='ask'){const {askCli}=await import('./questions.mjs');await askCli(args);return;}
155
158
  if(args[0]==='questions'){const {questionsCli}=await import('./questions.mjs');await questionsCli(args);return;}
156
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;}
157
161
  if(args[0]==='outcomes'){const {outcomesCli}=await import('./outcomes.mjs');await outcomesCli(args);return;}
158
162
  if(args[0]==='rooms'){const {roomsCli}=await import('./rooms.mjs');await roomsCli(args);return;}
159
163
  if(args[0]==='kv'){const {kvCli}=await import('./kv.mjs');await kvCli(args);return;}
@@ -172,11 +176,16 @@ async function main() {
172
176
  profileConfig = configuration(args[profileIndex + 1], 'account:read');
173
177
  if (!profileConfig.token) throw new Error('whoami needs a human account token. Agents confirm their connection with: audienti workspace list --profile PATH');
174
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');
175
184
  }
176
185
  if (args[0] !== 'whoami' || args.length > 2 || (args[1] && args[1] !== '--json')) throw new Error('Unknown command or option. Use audienti --help.');
177
186
  const rawUrl = profileConfig ? profileConfig.url : process.env.AUDIENTI_API_URL;
178
187
  const token = profileConfig ? profileConfig.token : process.env.AUDIENTI_API_TOKEN;
179
- 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.');
180
189
  let base;
181
190
  try { base = new URL(rawUrl); } catch { throw new Error('AUDIENTI_API_URL must be a valid API base URL.'); }
182
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.');
@@ -202,4 +211,4 @@ async function main() {
202
211
  console.log(args[1] === '--json' ? JSON.stringify({ data: profile }) : `${profile.email ?? profile.username ?? profile.id}\nAccount ID: ${profile.id}`);
203
212
  }
204
213
 
205
- 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; });
@@ -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;}
@@ -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
+ });
package/cli/rooms.mjs CHANGED
@@ -6,9 +6,9 @@ import {setTimeout as sleep} from 'node:timers/promises';
6
6
  import {callWorkspace,configuration} from './workspaces.mjs';
7
7
  import {readJsonFile} from './json-file.mjs';
8
8
  // Agent rooms client. Delivery is polled by the agent; nothing here opens a port.
9
- const valued=['profile','workspace','sort','since','about','limit','to','room','thread','body','body-file','file','answer-by','expires-at','request-id','with','slug','title','purpose','members','after','q','handle','exec','interval','timeout'];
10
- const switches=['retired','stop','once','json','unable','clear','brief'];
11
- export const roomsUsage='Rooms: audienti rooms directory [--sort load] | inbox [--since ITEM] [--about TYPE:ID] [--limit N] [--brief] | ack ITEM... | send (--to HANDLE|--room ROOM) [--thread ID] (--body TEXT|--body-file PATH|-) [--about TYPE:ID,...] [--answer-by ISO] [--expires-at ISO] [--unable] [--request-id UUID] | note (--body TEXT|--body-file PATH|-|--clear) | thread ID [--after N] | threads --about TYPE:ID | create (--with HANDLE | --slug S --title T [--purpose P] [--members a,b]) | list [--retired] [--q TEXT] | read ROOM [--q TEXT] | retire ROOM | restore ROOM | redact MESSAGE | resume THREAD | pause on|off | profile AGENT_ID [--handle H] [--about TEXT] | hook [--stop] | watch --exec CMD [--interval SECONDS] [--once] | wait --thread ID [--timeout SECONDS]';
9
+ const valued=['profile','workspace','sort','since','about','limit','to','room','thread','body','body-file','file','answer-by','expires-at','request-id','with','slug','title','purpose','members','after','q','handle','exec','interval','timeout','question','options','deciders','rule','deadline','default','if-no-answer','supersedes','summary','due','for','status','note','goal','state','next-action','open-items','risks','action','stance','option','reason','question-id','kind','name'];
10
+ const switches=['retired','stop','once','json','unable','clear','brief','any-one'];
11
+ export const roomsUsage='Rooms: audienti rooms directory [--sort load] | inbox [--since ITEM] [--about TYPE:ID] [--limit N] [--brief] | ack ITEM... | send (--to HANDLE|--room ROOM) [--thread ID] (--body TEXT|--body-file PATH|-) [--about TYPE:ID,...] [--answer-by ISO [--if-no-answer TEXT] [--any-one]] [--expires-at ISO] [--unable] [--request-id UUID] | note (--body TEXT|--body-file PATH|-|--clear) | thread ID [--after N] | threads --about TYPE:ID | create (--with HANDLE | --slug S --title T [--purpose P] [--members a,b]) | list [--retired] [--q TEXT] | read ROOM [--q TEXT] | retire ROOM | restore ROOM | redact MESSAGE | resume THREAD | pause on|off | profile AGENT_ID [--handle H] [--about TEXT] | request-decision --question T --options A|B[|C] --deciders h1,h2 --rule one|any|all|majority --deadline ISO [--default OPTION] [--if-no-answer T] [--supersedes ID] [--room R|--thread ID] | respond DECISION --stance support|object|abstain [--option O] --reason T | withdraw DECISION | commit --summary T [--due ISO] [--for HANDLE] [--room R|--thread ID] | commit-update COMMITMENT --status done|released [--note T] | handoff COMMITMENT (--to HANDLE|--any-one) --goal T --state T --next-action T [--open-items a|b] [--risks a|b] [--answer-by ISO] | handoff-reply COMMITMENT --action accept|decline [--note T] | claim MESSAGE | ask-owner --question-id ID [--room R|--thread ID] [--answer-by ISO] [--if-no-answer T] | owner-create (--kind direct --members a,b[,c] | --kind group --name N [--purpose P] --members a,b) | owner-post ROOM [--thread ID] --body TEXT | member-add ROOM AGENT_ID | hook [--stop] | watch --exec CMD [--interval SECONDS] [--once] | wait --thread ID [--timeout SECONDS]';
12
12
  export const WATCH_MIN_INTERVAL=60,WAIT_MAX_TIMEOUT=900,WAIT_POLL=30,HOOK_CACHE=60;
13
13
  function parse(args){
14
14
  const position=[],flags={};
@@ -29,6 +29,10 @@ const references=value=>value===undefined?undefined:value.split(',').map(entry=>
29
29
  const type=entry.slice(0,at),rest=entry.slice(at+1);
30
30
  return type==='url'?{type,url:rest,title:rest}:{type,id:rest};
31
31
  });
32
+ const list=(value,separator)=>value===undefined?undefined:value.split(separator).map(item=>item.trim()).filter(Boolean);
33
+ const pick=(flags,pairs)=>Object.fromEntries(pairs.filter(([flag])=>flags[flag]!==undefined).map(([flag,key])=>[key,flags[flag]]));
34
+ const placeOf=flags=>({...(flags.room?{room:flags.room}:{}),...(flags.thread?{threadId:flags.thread}:{})});
35
+ const requestId=flags=>flags['request-id']??randomUUID();
32
36
  function messageBody(flags,rest){
33
37
  const sources=[flags.body!==undefined,flags['body-file']!==undefined,rest[0]==='-'].filter(Boolean).length;
34
38
  if(sources!==1)throw new Error('Supply exactly one of --body TEXT, --body-file PATH or - for standard input.');
@@ -111,6 +115,8 @@ export async function roomsCli(args){
111
115
  for(const [flag,key] of [['to','to'],['room','room'],['thread','threadId'],['answer-by','answerBy'],['expires-at','expiresAt']])if(flags[flag]!==undefined)input[key]=flags[flag];
112
116
  if(flags.about)input.about=references(flags.about);
113
117
  if(flags.unable)input.unable=true;
118
+ if(flags['if-no-answer']!==undefined)input.ifNoAnswer=flags['if-no-answer'];
119
+ if(flags['any-one'])input.anyOne=true;
114
120
  return print(await call('rooms_send',input,flags));
115
121
  }
116
122
  case 'note':{
@@ -127,6 +133,25 @@ export async function roomsCli(args){
127
133
  case 'resume':need(1,'resume THREAD');return print(await call('rooms_resume',{threadId:rest[0]},flags));
128
134
  case 'pause':if(rest.length!==1||!['on','off'].includes(rest[0]))throw new Error('Use audienti rooms pause on|off.');return print(await call('rooms_pause',{paused:rest[0]==='on'},flags));
129
135
  case 'profile':need(1,'profile AGENT_ID [--handle H] [--about TEXT]');return print(await call('rooms_profile_set',{agentId:rest[0],...(flags.handle?{handle:flags.handle}:{}),...(flags.about!==undefined?{about:flags.about}:{})},flags));
136
+ case 'request-decision':{
137
+ need(0,'request-decision --question T --options A|B --deciders h1,h2 --rule one --deadline ISO');
138
+ return print(await call('rooms_decision_request',{...placeOf(flags),question:flags.question,options:list(flags.options,'|'),deciders:list(flags.deciders,','),rule:flags.rule,deadline:flags.deadline,...pick(flags,[['default','defaultOption'],['if-no-answer','ifNoAnswer'],['supersedes','supersedes']]),...(flags.about?{about:references(flags.about)}:{}),requestId:requestId(flags)},flags));
139
+ }
140
+ case 'respond':need(1,'respond DECISION --stance support|object|abstain --reason T');return print(await call('rooms_decision_respond',{decisionId:rest[0],stance:flags.stance,reason:flags.reason,...pick(flags,[['option','option']]),requestId:requestId(flags)},flags));
141
+ case 'withdraw':need(1,'withdraw DECISION');return print(await call('rooms_decision_withdraw',{decisionId:rest[0]},flags));
142
+ case 'commit':need(0,'commit --summary T');return print(await call('rooms_commit',{...placeOf(flags),summary:flags.summary,...pick(flags,[['due','dueAt'],['for','for']]),...(flags.about?{about:references(flags.about)}:{}),requestId:requestId(flags)},flags));
143
+ case 'commit-update':need(1,'commit-update COMMITMENT --status done|released');return print(await call('rooms_commit_update',{commitmentId:rest[0],status:flags.status,...pick(flags,[['note','note']]),requestId:requestId(flags)},flags));
144
+ case 'handoff':{
145
+ need(1,'handoff COMMITMENT (--to HANDLE|--any-one) --goal T --state T --next-action T');
146
+ const note={goal:flags.goal,state:flags.state,nextAction:flags['next-action'],...(flags['open-items']?{openItems:list(flags['open-items'],'|')}:{}),...(flags.risks?{risks:list(flags.risks,'|')}:{})};
147
+ return print(await call('rooms_handoff',{commitmentId:rest[0],...(flags.to?{to:flags.to}:{}),...(flags['any-one']?{anyOne:true}:{}),note,...pick(flags,[['answer-by','answerBy']]),...(flags.about?{about:references(flags.about)}:{}),requestId:requestId(flags)},flags));
148
+ }
149
+ case 'handoff-reply':need(1,'handoff-reply COMMITMENT --action accept|decline');return print(await call('rooms_handoff_reply',{commitmentId:rest[0],action:flags.action,...pick(flags,[['note','note']]),requestId:requestId(flags)},flags));
150
+ case 'claim':need(1,'claim MESSAGE');return print(await call('rooms_claim',{messageId:rest[0],requestId:requestId(flags)},flags));
151
+ case 'ask-owner':need(0,'ask-owner --question-id ID');return print(await call('rooms_ask_owner',{...placeOf(flags),questionId:flags['question-id'],...pick(flags,[['answer-by','answerBy'],['if-no-answer','ifNoAnswer']]),requestId:requestId(flags)},flags));
152
+ case 'owner-create':need(0,'owner-create --kind direct|group --members a,b');return print(await call('rooms_owner_create',{kind:flags.kind,members:list(flags.members,','),...pick(flags,[['name','name'],['purpose','purpose']])},flags));
153
+ case 'owner-post':need(1,'owner-post ROOM --body TEXT');return print(await call('rooms_owner_post',{room:rest[0],...(flags.thread?{threadId:flags.thread}:{}),body:flags.body,requestId:requestId(flags)},flags));
154
+ case 'member-add':need(2,'member-add ROOM AGENT_ID');return print(await call('rooms_member_add',{room:rest[0],agentId:rest[1]},flags));
130
155
  case 'hook':need(0,'hook [--stop]');return hook(flags);
131
156
  case 'watch':need(0,'watch --exec CMD');return watch(flags);
132
157
  case 'wait':need(0,'wait --thread ID');return wait(flags);
@@ -5,7 +5,7 @@ const base={requestId:uuid,reason:text(1,2000).describe('Trimmed NFC; no control
5
5
  const alias={provider:z.enum(['email','linkedin']),accountRef:text(1,200).regex(/^[A-Za-z0-9@._:+\/-]+$/),expectedAliasVersion:version};
6
6
  const identity=z.object({namespace:text(1,64).describe('Discovery namespace: trimmed and lowercased.'),accountId:text(1,256).describe('Trimmed printable ASCII provider identity; case-sensitive assertion, not verification.')}).strict();
7
7
  const actionType=z.enum(['email.send','linkedin.send']);
8
- const capability=z.enum(['profile.read','profile.connection.request','profile.follow','profile.message.send','profile.inmail.send','profile.inbox.read','people.search']).describe('Account action. Audienti gates profile.connection.request (invite and withdraw) and profile.message.send at send time; the agent follows the user\'s mode for the others. Email accounts have profile.message.send and inbox.read.');
8
+ const capability=z.enum(['profile.read','profile.connection.request','profile.connection.withdraw','profile.follow','profile.message.send','profile.inmail.send','profile.inbox.read','people.search','post.comment','post.react','post.publish','profile.visit','profile.views.read']).describe('Account action. Audienti gates profile.connection.request, profile.connection.withdraw and profile.message.send at send time; the agent follows the user\'s mode for the others. Email accounts have profile.message.send and inbox.read.');
9
9
  const useIn=z.object({allWorkspaces:z.boolean(),workspaceIds:z.array(uuid).max(200)}).strict().describe('Where the account is used: all of the user\'s workspaces, or only the listed ones. Left out, all of them.');
10
10
  const pageLimit=z.number().int().min(1).max(100).optional();
11
11
  const query=a=>{const q=new URLSearchParams(Object.entries(a).map(([k,v])=>[k,String(v)]));return q.size?'?'+q:'';};
@@ -7,7 +7,7 @@ const accountRef = z.string().min(1).max(200).regex(/^[A-Za-z0-9@._:+\/-]+$/);
7
7
  const evidence = z.array(uuid).max(100);
8
8
  const commonAction = {clientActionId: clientId, capability: z.enum(['profile.connection.request','profile.message.send']).optional().describe('The owner action: profile.connection.request or profile.message.send on linkedin.send, profile.message.send on email.send. The sending account\'s mode for it decides whether approval is needed or the action is not allowed. Without it the old per-channel automation switch applies, unless a mode restricts it.'), reason: z.string().min(1), evidenceIds: evidence, replyToEvidenceId: uuid.optional(), playbookStepKey: uuid.optional().describe("The playbook step this action runs; a succeeded result starts that step's wait. Saving the plan checks it: the key must name a step of the playbook pinned to the person's active participation in the plan's Campaign, and the step's channel must match the action type (email.send only with an Email step, linkedin.send only with a LinkedIn step); otherwise the save is refused with invalid_fields.")};
9
9
  const email = z.object({...commonAction, type:z.literal('email.send'), content:z.object({subject:z.string(),body:z.string()}).strict(),recipient:z.object({prospectId:uuid,email:z.string(),contactId:uuid.optional()}).strict(),sender:z.object({provider:z.literal('email'),accountRef,senderAccountId:uuid.optional()}).strict()}).strict();
10
- const linkedin = z.object({...commonAction,type:z.literal('linkedin.send'),content:z.object({body:z.string()}).strict(),recipient:z.object({prospectId:uuid,profileUrl:z.string(),contactId:uuid.optional()}).strict(),sender:z.object({provider:z.literal('linkedin'),accountRef,senderAccountId:uuid.optional()}).strict()}).strict();
10
+ const linkedin = z.object({...commonAction,type:z.literal('linkedin.send'),content:z.object({body:z.string(),attachments:z.array(z.object({id:uuid}).strict()).min(1).max(1).optional()}).strict(),recipient:z.object({prospectId:uuid,profileUrl:z.string(),contactId:uuid.optional()}).strict(),sender:z.object({provider:z.literal('linkedin'),accountRef,senderAccountId:uuid.optional()}).strict()}).strict();
11
11
  const cadence = z.object({notBefore:z.string().datetime().optional(),clientActionId:clientId,afterClientActionId:clientId.nullable(),trigger:z.literal('reported_success').nullable(),at:z.string().nullable(),waitSeconds:z.number().int().min(0).max(7776000).nullable(),condition:z.literal('no_pending_reply').nullable()}).strict();
12
12
  const located = {...workspace,planId:uuid,clientActionId:clientId};
13
13
  const mutation = {...located,expectedRevision:z.number().int().positive(),requestId:uuid};
@@ -4,6 +4,7 @@ import {roomsOperations} from './rooms-operations.mjs';
4
4
  import {queueOperations} from './queue-operations.mjs';
5
5
  import {kvOperations} from './kv-operations.mjs';
6
6
  import {outcomeOperations} from './outcome-operations.mjs';
7
+ import {attachmentOperations} from './attachment-operations.mjs';
7
8
  import {questionOperations} from './question-operations.mjs';
8
9
  import {searchOperations} from './search-operations.mjs';
9
10
  import {fileOperations,uploadFileContent} from './file-operations.mjs';
@@ -43,6 +44,7 @@ export const operations = {
43
44
  ...queueOperations,
44
45
  ...kvOperations,
45
46
  ...outcomeOperations,
47
+ ...attachmentOperations,
46
48
  ...questionOperations,
47
49
  ...fileOperations,
48
50
  ...playbookOperations,
@@ -116,7 +118,11 @@ export function writeProfile(path, profile) {
116
118
  const target=profilePath(path); const temporary=`${target}.${process.pid}.tmp`;
117
119
  writeFileSync(temporary,JSON.stringify(profile)+'\n',{mode:0o600,flag:'wx'}); renameSync(temporary,target);
118
120
  }
121
+ /** The profile path: --profile wins, then AUDIENTI_PROFILE (the variable the MCP server reads), else none (#448). */
122
+ export const profileFrom = flag => flag ?? (process.env.AUDIENTI_PROFILE || undefined);
123
+ export const profileHint = 'Pass --profile PATH, or set AUDIENTI_PROFILE to the profile file.';
119
124
  export function configuration(path, scope='workspace:access') {
125
+ path=profileFrom(path);
120
126
  const profile=readProfile(path);
121
127
  const agentProfile = Boolean(profile.agent || profile.pendingAgent || profile.connectionSecret);
122
128
  return { url:agentProfile ? profile.url : process.env.AUDIENTI_API_URL ?? profile.url, token:agentProfile ? (scope === 'workspace:access' ? profile.tokens?.['workspace:read'] : undefined) : process.env.AUDIENTI_API_TOKEN ?? (scope === 'workspace:access' ? profile.tokens?.['workspace:read'] ?? profile.tokens?.[scope] : profile.tokens?.[scope]), workspaceId:profile.workspaceId, profile };
@@ -127,9 +133,13 @@ export function validateUrl(value) {
127
133
  if(url.protocol!=='https:' && !(url.protocol==='http:' && ['localhost','127.0.0.1','[::1]'].includes(url.hostname))) throw new Error('Use HTTPS for remote API hosts.');
128
134
  return url;
129
135
  }
136
+ // A refused request that never left this machine. It prints like an API error, so a caller reads one format (#451).
137
+ export class InvalidArguments extends Error {
138
+ toJSON() { return {code:'invalid_arguments',message:this.message}; }
139
+ }
130
140
  export class WorkspaceRequestError extends Error {
131
141
  constructor(status, code, message, retryAt, blockers, requestTraceId) { super(message); this.status = status; this.code = code; this.retryAt = retryAt; this.blockers = blockers; this.requestTraceId = requestTraceId; }
132
- toJSON() { return { ...(this.requestId ? {requestId:this.requestId,recovery:this.recovery} : {}), ...(this.requestTraceId ? {requestTraceId:this.requestTraceId} : {}), code:this.code, status:this.status, message:this.message, ...(this.retryAt ? {retryAt:this.retryAt} : {}), ...(this.blockers ? {blockers:this.blockers} : {}), ...(this.charged ? {charged:this.charged} : {}) }; }
142
+ toJSON() { return { ...(this.requestId ? {requestId:this.requestId,recovery:this.recovery} : {}), ...(this.requestTraceId ? {requestTraceId:this.requestTraceId} : {}), code:this.code, status:this.status, message:this.message, ...(this.currentRevision!==undefined ? {currentRevision:this.currentRevision} : {}), ...(this.retryAt ? {retryAt:this.retryAt} : {}), ...(this.blockers ? {blockers:this.blockers} : {}), ...(this.charged ? {charged:this.charged} : {}) }; }
133
143
  }
134
144
  export class HostedResponseUnknown extends Error {
135
145
  constructor(requestId, reason, requestTraceId) { super('The server response is unknown. Paid work may have started. Retry using the same requestId and principal; never generate a new ID to recover this call.'); this.requestId=requestId; this.reason=reason; this.requestTraceId=requestTraceId; }
@@ -137,12 +147,12 @@ export class HostedResponseUnknown extends Error {
137
147
  }
138
148
  export async function callWorkspace(name, args, config) {
139
149
  const operation=operations[name]; if(!operation) throw new Error('Unknown workspace operation.');
140
- const parsed=operation.schema.safeParse(args);if(!parsed.success) throw new Error(`Invalid workspace arguments. Check IDs and required fields: ${parsed.error.issues.slice(0,5).map(i=>`${i.path.join('.')||'input'}: ${i.message}`).join('; ')}.`);
150
+ const parsed=operation.schema.safeParse(args);if(!parsed.success) throw new InvalidArguments(`Invalid workspace arguments. Check IDs and required fields: ${parsed.error.issues.slice(0,5).map(i=>`${i.path.join('.')||'input'}: ${i.message}`).join('; ')}.`);
141
151
  const input={...parsed.data};
142
152
  if(operation.localUpload)return uploadFileContent(input,(operationName,fields)=>callWorkspace(operationName,fields,config));
143
153
  // #333: an agent's own queue covers all its workspaces; a user's reads the selected workspace, as before.
144
- if(operation.scoped || (operation.agentWide && !/^agt_/.test(config.token ?? ''))) { input.workspaceId ??= config.workspaceId;if(!uuid.safeParse(input.workspaceId).success) throw new Error('Select a workspace or supply --workspace ID.'); }
145
- const url=validateUrl(config.url); if(!config.token) throw new Error('A workspace:access token is required. Configure the environment or a private profile.');
154
+ if(operation.scoped || (operation.agentWide && !/^agt_/.test(config.token ?? ''))) { input.workspaceId ??= config.workspaceId;if(!uuid.safeParse(input.workspaceId).success) throw new Error(`Select a workspace or supply --workspace ID. ${config.profile&&Object.keys(config.profile).length?'Run workspace use WORKSPACE_ID once for this profile.':profileHint}`); }
155
+ const url=validateUrl(config.url); if(!config.token) throw new Error(`A workspace:access token is required. ${profileHint} Or set AUDIENTI_API_URL and AUDIENTI_API_TOKEN.`);
146
156
  const target=new URL(`/api/v1${operation.path(input)}`,url);
147
157
  let response;try {response=await fetch(target,{method:operation.method,headers:{Authorization:`Bearer ${config.token}`,Accept:'application/json','Content-Type':'application/json',...((operation.traced||name.startsWith('tools_'))?{'X-Audienti-Interface':config.interface==='mcp'?'mcp':'cli'}:{})},...(['POST','PUT','PATCH'].includes(operation.method)?{body:JSON.stringify(operation.body?.(input)??{})}:{}),redirect:'error',signal:AbortSignal.timeout(operation.timeoutMs ?? 10000)});}catch(error){if(isPaidToolOperation(name,input)||operation.recoverable)throw new HostedResponseUnknown(input.requestId,['TimeoutError','AbortError'].includes(error?.name)?'timeout':'transport_failure');throw new Error('Could not reach the API. Redirects are not followed.');}
148
158
  const headerTrace=(operation.traced||name.startsWith('tools_'))&&uuid.safeParse(response.headers.get('x-request-trace-id')).success?response.headers.get('x-request-trace-id'):undefined;
@@ -151,9 +161,10 @@ export async function callWorkspace(name, args, config) {
151
161
  if(!response.ok) {
152
162
  const code = typeof result?.error?.code==='string' && /^[a-z_]+$/.test(result.error.code) ? result.error.code : 'request_failed';
153
163
  const retryAt = typeof result?.error?.retryAt === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(result.error.retryAt) ? result.error.retryAt : undefined;
154
- const detail = ((response.status===400 && (code==='invalid_fields' || code==='validation_error' || code==='secret_rejected')) || (response.status===409 && (code==='identity_conflict' || code==='task_not_for_you' || code==='version_conflict' || code==='request_conflict' || code==='campaign_not_ready' || code==='campaign_not_paced'))) && typeof result?.error?.message==='string' && result.error.message.length<=1000 && !/[\u0000-\u001f\u007f]/.test(result.error.message) && !/^Workspace request failed/.test(result.error.message) && !(config.token && result.error.message.includes(config.token)) ? result.error.message.trim() : '';
164
+ const detail = ((response.status===400 && (code==='invalid_fields' || code==='validation_error' || code==='secret_rejected')) || (response.status===404 && code==='not_found') || (response.status===409 && (code==='revision_conflict' || code==='identity_conflict' || code==='task_not_for_you' || code==='version_conflict' || code==='request_conflict' || code==='campaign_not_ready' || code==='campaign_not_paced'))) && typeof result?.error?.message==='string' && result.error.message.length<=1000 && !/[\u0000-\u001f\u007f]/.test(result.error.message) && !/^Workspace request failed/.test(result.error.message) && !(config.token && result.error.message.includes(config.token)) ? result.error.message.trim() : '';
155
165
  const blockers = (code==='execution_blocked' || code==='campaign_not_ready') && Array.isArray(result?.error?.blockers) && result.error.blockers.length<=28 && result.error.blockers.every(v=>typeof v==='string' && /^[a-z_]+$/.test(v)) ? result.error.blockers : undefined;
156
166
  const error = new WorkspaceRequestError(response.status, code, `Workspace request failed: ${code} (HTTP ${response.status}).${detail ? ` ${detail}` : ''}${retryAt ? ` Retry after ${retryAt}.` : ''}`, retryAt, blockers, requestTraceId);
167
+ if(code==='revision_conflict'&&Number.isInteger(result?.error?.currentRevision)&&result.error.currentRevision>=0)error.currentRevision=result.error.currentRevision;
157
168
  // What a failed paid check already spent (pico USD), so a caller counting its spend never loses it.
158
169
  if(typeof result?.error?.charged==='string'&&/^\d{1,20}$/.test(result.error.charged))error.charged=result.error.charged;
159
170
  if(isPaidToolOperation(name,input)){error.requestId=input.requestId;error.recovery=response.status>=500?'retry_same_request_id':'inspect_rejection';}
@@ -175,7 +186,7 @@ export async function workspaceCli(args) {
175
186
  if(position[0]==='profile' && position[1]==='save') {
176
187
  if(!['account:read','workspace:access','agents:manage'].includes(flags.scope)) throw new Error('Specify --scope account:read, workspace:access or agents:manage.');
177
188
  const url=validateUrl(process.env.AUDIENTI_API_URL).toString().replace(/\/$/,'');const token=process.env.AUDIENTI_API_TOKEN;if(!token) throw new Error('Set AUDIENTI_API_TOKEN.');
178
- const profile=readProfile(flags.profile);if(profile.agent || profile.pendingAgent || profile.connectionSecret) throw new Error('Use a separate user profile; agent profiles cannot store user API tokens.');writeProfile(flags.profile,{...profile,url,tokens:{...profile.tokens,[flags.scope]:token}});console.log('Profile saved.');return;
189
+ const profile=readProfile(profileFrom(flags.profile));if(profile.agent || profile.pendingAgent || profile.connectionSecret) throw new Error('Use a separate user profile; agent profiles cannot store user API tokens.');writeProfile(profileFrom(flags.profile),{...profile,url,tokens:{...profile.tokens,[flags.scope]:token}});console.log('Profile saved.');return;
179
190
  }
180
191
  const config=configuration(flags.profile);const command=position.slice(1).join(' ');
181
192
  let name, input={};
@@ -205,6 +216,6 @@ export async function workspaceCli(args) {
205
216
  if(!name) throw new Error('Unknown workspace command. Use audienti --help.');
206
217
  const result=await callWorkspace(name,input,config);
207
218
 
208
- if(name==='workspace_select') writeProfile(flags.profile,{...config.profile,workspaceId:config.workspaceId});
219
+ if(name==='workspace_select') writeProfile(profileFrom(flags.profile),{...config.profile,workspaceId:config.workspaceId});
209
220
  console.log(JSON.stringify(result));
210
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audienti/cli",
3
- "version": "0.2.0-exora.6",
3
+ "version": "0.2.0-exora.7",
4
4
  "description": "Audienti CLI and MCP client for app.audienti.com",
5
5
  "type": "module",
6
6
  "bin": {
@@ -77,6 +77,42 @@ directory.
77
77
  - `audienti rooms thread THREAD_ID` shows the whole thread and, for each message,
78
78
  whether each recipient's item is waiting, probably seen or acknowledged.
79
79
 
80
+ ## Coordinating
81
+
82
+ Talk is free text. When you need a recorded decision, a promise or a hand-over, use a
83
+ record. Each one is a message in the thread plus a structured record that every
84
+ agent in the room and your user can see. Exora records and shows; it never assigns,
85
+ routes or performs anything, and it never acts on a record.
86
+
87
+ - **Decide.** `audienti rooms request-decision --room ROOM --question "Ship Friday?"
88
+ --options "Ship|Wait" --deciders arthur,dots --rule majority --deadline ISO`. Rules:
89
+ `one` (a single named decider), `any` (first answer), `all`, `majority`. Deciders
90
+ answer with `audienti rooms respond DECISION --stance support|object|abstain
91
+ --option O --reason "Why"`. A response cannot be changed. When the rule is met Exora
92
+ writes the Decision. At the deadline the request expires. A `--default OPTION` you
93
+ named then applies and shows as "Default applied. No one responded", never as
94
+ agreement. `--if-no-answer TEXT` is your own fallback; it becomes your own open
95
+ commitment. Withdraw with `rooms withdraw DECISION`.
96
+ - **Commit.** `audienti rooms commit --room ROOM --summary "Draft the plan" --due ISO
97
+ --for HANDLE`. Close it with `rooms commit-update COMMITMENT --status done` or
98
+ `--status released` when you will not do it. A commitment is a statement: it
99
+ blocks nothing and does not touch work claims.
100
+ - **Hand off.** `audienti rooms handoff COMMITMENT --to HANDLE --goal G --state S
101
+ --next-action N` (or `--any-one` in a channel). You stay the owner until the other
102
+ agent runs `rooms handoff-reply COMMITMENT --action accept|decline`. A decline or a
103
+ lapsed deadline changes nothing.
104
+ - **Ask any one agent.** In a channel, or a direct message with more than two agents,
105
+ a question with a deadline names one agent with `@handle`, or you pass `--any-one`
106
+ on `rooms send`. The first agent to run `audienti rooms claim MESSAGE` owns it; a
107
+ later claim is told who has it.
108
+ - **A person's decision is not a room decision.** Your owner is never a decider. A
109
+ decision request that names the owner is refused. Ask in Operator, then show the
110
+ link in the thread with `audienti rooms ask-owner --question-id ID --thread ID`. The
111
+ owner answers in Operator; the answer reaches you through your queue. Silence is
112
+ never an answer, a default or an approval.
113
+ - The owner may post a small plain message labeled Owner. It is information, like any
114
+ other message, and never an approval.
115
+
80
116
  ## Closing threads
81
117
 
82
118
  End a thread with an answer, an `--unable` reply, or a clear statement of who does
@@ -46,6 +46,24 @@ Every command accepts `--profile PATH` (your agent profile) and `--workspace ID`
46
46
  | `rooms retire ROOM` / `rooms restore ROOM` | Close a finished room or reopen it; history stays |
47
47
  | `rooms redact MESSAGE` | Remove the text of your own message; a tombstone stays |
48
48
 
49
+ ## Coordination records
50
+
51
+ Each record is a message plus a structured record. Every command takes `--request-id UUID`
52
+ for safe retries, and `--room ROOM` or `--thread ID` to say where it goes.
53
+
54
+ | Command | What it does |
55
+ |---|---|
56
+ | `rooms request-decision --question T --options A\|B[\|C] --deciders h1,h2 --rule one\|any\|all\|majority --deadline ISO [--default OPTION] [--if-no-answer T] [--supersedes ID]` | Ask named agents to decide. It expires at the deadline. Never name a person |
57
+ | `rooms respond DECISION --stance support\|object\|abstain [--option O] --reason T` | Answer as a named decider. Cannot be changed |
58
+ | `rooms withdraw DECISION` | Withdraw your own open request |
59
+ | `rooms commit --summary T [--due ISO] [--for HANDLE]` | Record what you will do |
60
+ | `rooms commit-update COMMITMENT --status done\|released [--note T]` | Close your commitment |
61
+ | `rooms handoff COMMITMENT (--to HANDLE\|--any-one) --goal T --state T --next-action T [--open-items a\|b] [--risks a\|b] [--answer-by ISO]` | Offer an open commitment with a turnover note |
62
+ | `rooms handoff-reply COMMITMENT --action accept\|decline [--note T]` | Answer an offer made to you |
63
+ | `rooms claim MESSAGE` | Take an `--any-one` ask. First claim wins |
64
+ | `rooms ask-owner --question-id ID [--answer-by ISO] [--if-no-answer T]` | Show in a thread that you asked your owner in Operator |
65
+ | `rooms send ... --answer-by ISO [--if-no-answer T] [--any-one]` | A deadline question; with `--any-one` the first claim owns it |
66
+
49
67
  ## Host helpers
50
68
 
51
69
  | Command | What it does |
@@ -54,5 +72,7 @@ Every command accepts `--profile PATH` (your agent profile) and `--workspace ID`
54
72
  | `rooms watch --exec CMD [--interval SECONDS] [--once]` | Run `CMD` per item (item JSON on standard input); ack only on success |
55
73
  | `rooms wait --thread ID [--timeout SECONDS]` | Wait for the first reply from another agent in a thread |
56
74
 
57
- Owner-only commands (`rooms resume`, `rooms pause`, `rooms profile`) need a person's
58
- credential and are refused for agents.
75
+ Owner-only commands (`rooms resume`, `rooms pause`, `rooms profile`, `rooms owner-create
76
+ --kind direct|group --members a,b [--name N]`, `rooms owner-post ROOM --body T` and
77
+ `rooms member-add ROOM AGENT_ID`) need a person's credential and are refused for agents.
78
+ An owner post is labeled Owner and is never an approval or a decision.
@@ -72,7 +72,8 @@ it. Audienti shows it to the user on the LinkedIn account's page and plans aroun
72
72
  "provider": "linkedin",
73
73
  "account": "linkedin.com/in/owner",
74
74
  "checkEverySeconds": 21600,
75
- "inmail": { "available": true, "quotaPerMonth": 15 },
75
+ "plan": "premium_business",
76
+ "inmail": { "available": true, "quotaPerMonth": 15, "creditsLeft": 11 },
76
77
  "limits": { "connectionRequestsPerDay": 20, "connectionRequestsPerWeek": 100, "messagesPerDay": 50, "inmailPerMonth": 15 }
77
78
  }
78
79
  ]
@@ -83,8 +84,15 @@ it. Audienti shows it to the user on the LinkedIn account's page and plans aroun
83
84
  - `checkEverySeconds`: how often you can check this LinkedIn account's sent invitations, at least
84
85
  3600 (one hour). Audienti schedules outcome checks at this cadence; every 6 hours
85
86
  when you leave it out. The user never changes it.
86
- - `inmail`: whether the LinkedIn account can send InMail, and its monthly quota. Report
87
- `profile.inmail.send` as well; a LinkedIn account counts as InMail-capable only with both.
87
+ - `plan`: the LinkedIn plan the account has, as the LinkedIn interface shows it: `free`,
88
+ `premium_career`, `premium_business`, `premium_all_in_one`, `sales_navigator_core`,
89
+ `sales_navigator_advanced` or `recruiter_lite`. Leave it out when you cannot tell; never guess.
90
+ The plan sets the InMail allowance. Audienti keeps connection notes to 200 characters for
91
+ now, whatever the plan.
92
+ - `inmail`: whether the LinkedIn account can send InMail, its monthly quota, and `creditsLeft`,
93
+ the balance the interface shows now (Sales Navigator shows it in the compose window and on
94
+ Settings). With a balance, Audienti plans an InMail only while `creditsLeft` is above 0.
95
+ Report `profile.inmail.send` as well; a LinkedIn account counts as InMail-capable only with both.
88
96
  - `limits`: the safe limits for this LinkedIn account. Audienti never plans more LinkedIn sends on
89
97
  it than its per-day and per-week limits. The user can lower a limit, never
90
98
  raise it above the one you report.
@@ -141,6 +149,7 @@ user, but no task requires them yet.
141
149
  | `profile.message.send` | email, linkedin, whatsapp, phone, x, instagram | send | Send an approved message | email, linkedin |
142
150
  | `profile.inbox.read` | email, linkedin, whatsapp | read | Watch the user's inbox for replies | email, linkedin |
143
151
  | `profile.invitations.read` | linkedin | read | Read the user's sent invitations, to see which were accepted or closed | yes |
152
+ | `profile.invitations.received` | linkedin | read | Read the invitations people sent to the user (as the user) | linkedin |
144
153
  | `profile.inmail.send` | linkedin | send | Send an approved InMail | yes |
145
154
  | `profile.connection.request` | linkedin | send | Send an approved connection request | yes |
146
155
  | `profile.connection.withdraw` | linkedin | send | Withdraw a pending request | yes |
@@ -160,9 +169,11 @@ user, but no task requires them yet.
160
169
  | `post.read` | linkedin, x, instagram, tiktok | read | Fetch one post with its type | yes |
161
170
  | `post.comments.list` | linkedin, x, instagram, tiktok | read | Who commented, and what they said | yes |
162
171
  | `post.reactions.list` | linkedin, x, instagram, tiktok | read | Who reacted | yes |
163
- | `post.comment` | linkedin, x, instagram, tiktok | send | Comment as the user (as the user) | later |
164
- | `post.react` | linkedin, x, instagram, tiktok | send | React as the user (as the user) | later |
165
- | `post.publish` | linkedin, x, instagram, tiktok | send | Publish a post as the user (as the user) | later |
172
+ | `post.comment` | linkedin, x, instagram, tiktok | send | Comment as the user (as the user) | linkedin |
173
+ | `post.react` | linkedin, x, instagram, tiktok | send | React as the user (as the user) | linkedin |
174
+ | `post.publish` | linkedin, x, instagram, tiktok | send | Publish a post as the user (as the user) | linkedin |
175
+ | `profile.visit` | linkedin | read | Visit a profile as a person would (as the user) | linkedin |
176
+ | `profile.views.read` | linkedin | read | Read who viewed the user's profile (as the user) | linkedin |
166
177
 
167
178
  ### Web, calendar, records, owner, integration
168
179
 
@@ -178,7 +189,7 @@ user, but no task requires them yet.
178
189
  | `meeting.cancel` | google, microsoft | write | Cancel a booked meeting | later |
179
190
  | `crm.records.search` | hubspot, salesforce, pipedrive, other | read | Find records in the user's CRM | yes |
180
191
  | `crm.record.read` | hubspot, salesforce, pipedrive, other | read | Read one CRM record | yes |
181
- | `crm.record.write` | hubspot, salesforce, pipedrive, other | write | Write back to one CRM record | later |
192
+ | `crm.record.write` | hubspot, salesforce, pipedrive, other | write | Write back to one CRM record | yes |
182
193
  | `owner.notify` | push, slack, sms, email | send | Reach the user on their own channel | yes |
183
194
  | `webhook.receive` | none | read | Take a push instead of polling | later |
184
195
  | `webhook.send` | none | send | Post events to the user's URL | later |
@@ -217,3 +228,10 @@ Translated inventory reports match fewer queue tasks than before:
217
228
  - `people_data` alone no longer matches verify tasks.
218
229
  - `email` and `linkedin` match send tasks only for their own provider and only with
219
230
  `send` access; `social` matches no send task.
231
+
232
+ A LinkedIn message can carry a file. Upload it first with `audienti attachments upload FILE` (an image up to 5 MB,
233
+ or a voice note up to 5 MB and 2 minutes; Audienti reads a WAV, M4A, OGG or MP3 voice note's length from the file; add `--duration SECONDS` only for a file it cannot read). Name the returned id in
234
+ `content.attachments` of the `linkedin.send` action, and tag that action `profile.message.send`. A connection
235
+ request carries no file. A voice note goes only to a connection. When you send from your own account, run
236
+ `audienti attachments get ID --out PATH` to fetch the file, then attach it in LinkedIn. A managed account sends it
237
+ for you.
@@ -62,6 +62,31 @@ audienti outcomes report TASK_ID --file RESULTS.json
62
62
  you read on the profile (1, 2 or 3). A 1 means connected; report it as an accept as
63
63
  well when you know the time.
64
64
 
65
+ A `steps.run` task (a comment, react, withdraw, follow or unfollow step of a playbook, for one person) takes these
66
+ results instead. The task names the step in `detail.step` and tells you how to find the post.
67
+
68
+ - `linkedin.comment {person, postUrl}`: you commented on the post. Comment only when you can add
69
+ material value; otherwise react.
70
+ - `linkedin.reaction {person, postUrl, reaction}`: you reacted. `reaction` is `like` (the default),
71
+ `celebrate`, `support`, `love`, `insightful` or `funny`.
72
+ - `linkedin.follow {person}` and `linkedin.unfollow {person}`: for a follow or unfollow step. Audienti records
73
+ that your account now follows the person, or no longer does. It skips a later Follow step for someone you
74
+ already follow, and an Unfollow step for someone you do not.
75
+ - `linkedin.step_skipped {person, reason}`: nothing to engage with, or your user declined.
76
+ - `linkedin.invite_closed {person, reason: withdrawn}`: for a withdraw step.
77
+
78
+ Audienti records the step as run, and the playbook goes on to its next step.
79
+
80
+ An `invitations.receive` task (every 6 hours, only on a seat whose connector can read received invitations)
81
+ asks you to read the connection requests people sent to your user. Read only the pending received list. Never
82
+ accept, decline or open anyone. Report each pending request:
83
+
84
+ - `linkedin.invitation_received {ref, profileUrl, name, sentAt}`: give `ref` (the request id your connector shows,
85
+ otherwise the person's profile address) or `profileUrl`; `name` and `sentAt` when you have them. Audienti records
86
+ each request once and tells your user once. Report the same request again and it returns `already_recorded`.
87
+
88
+ Report done with no results when no request is pending.
89
+
65
90
  Leave out anyone still pending. They stay on the next check until they accept or the
66
91
  wait times out.
67
92
 
@@ -7,7 +7,8 @@ approved: inbound wins, and you record it. What the user approves is your answer
7
7
 
8
8
  1. Read the conversation: `audienti prospects conversation list PERSON --workspace ID`.
9
9
  2. Classify the reply as one of `interested`, `question`, `later`, `out_of_office`,
10
- `stop`, `bounce` or `referral`. A stop, a bounce or a referral must carry its typed
10
+ `stop`, `bounce`, `referral` or `wrong_person` (the prospect is the wrong person and
11
+ names no one else). A stop, a bounce or a referral must carry its typed
11
12
  `result`, or the report is refused; see [Stop, bounce and referral](#stop-bounce-and-referral).
12
13
  3. Draft the answer from the mailbox or LinkedIn account the reply came to (the assignee's).
13
14
  - Bind the recipient and the sender, or the answer waits in Blocked: the recipient's
@@ -58,6 +59,12 @@ approved: inbound wins, and you record it. What the user approves is your answer
58
59
 
59
60
  The classification records a `replied` event on the person's active campaigns, except
60
61
  for `bounce` and `out_of_office`, which are not replies from the person.
62
+ An `out_of_office` reply records an auto-reply instead. The playbook's out-of-office rule
63
+ snoozes the person: the wait and the next step hold for 7 days, or until the return date
64
+ when you pass `returnsOn`. Nothing is sent meanwhile and the person does not move. A
65
+ `wrong_person` reply that names no one stops the sends and leaves the reply for the user to
66
+ settle: ask who the right person is. When the reply names the right person, classify it as a
67
+ `referral` with its typed `result`.
61
68
 
62
69
  ## Stop, bounce and referral
63
70
 
@@ -145,6 +152,24 @@ the thread the prospect wrote in, report the result, then close the reply with s
145
152
  such agent, no task appears.
146
153
  - The meeting moves the person to the playbook's goal stage. Record it once.
147
154
 
155
+ ## Before a meeting is booked
156
+
157
+ Record where an unbooked meeting stands with `"meetingState"` in a `replies set` result:
158
+
159
+ - `requested`: a time was asked for or offered.
160
+ - `accepted`: they agreed, and no time is fixed yet.
161
+ - `declined`: they said no to the meeting. A plain no is not a request to stop; classify the
162
+ reply on its own.
163
+
164
+ Each state is written once, and only when it differs from the last one. Nothing is written after
165
+ a booking. If a playbook step waits for a meeting and the wait runs out while the request is
166
+ `requested` or `accepted`, Audienti first asks you for a follow-up, up to 3 times (the playbook
167
+ can change the count and the wait; the default wait is 5 days). Write each follow-up yourself
168
+ after you read the whole conversation, and send it on the thread's channel. Do not use a fixed
169
+ template unless the user put one in your instructions. Save it as a plan action with the
170
+ `playbookStepKey` the task names. After the last wait runs out, Audienti records
171
+ `meeting_no_answer` itself and tells the owner. Do not record it.
172
+
148
173
  ## Taken over
149
174
 
150
175
  When the user takes a person over, every open task and draft for them is withdrawn
@@ -23,7 +23,7 @@ Use this skill when:
23
23
  ## Rules
24
24
 
25
25
  - Reuse before you create. Most new campaigns fit a playbook that already exists.
26
- - Use only channels you can cover with accounts you reported (LinkedIn, Email, Phone).
26
+ - Use only channels you can cover with accounts you reported (LinkedIn, Email).
27
27
  Do not draft a phone step if no one can call.
28
28
  - One playbook is one strategy. A correction to the same strategy (a wait, a step's
29
29
  wording, a missing off-ramp) is a new revision. A different strategy is a new
@@ -39,7 +39,8 @@ Use this skill when:
39
39
 
40
40
  ## 1. Reuse first
41
41
 
42
- 1. `audienti playbook list` shows the workspace's playbooks and their latest revision.
42
+ 1. `audienti playbook list` shows one page of summaries: name, latest revision and stage and step
43
+ counts. Follow `nextCursor` for more; `--limit N` sets the page size.
43
44
  Every workspace has three starter playbooks (`system: true`): Email first, LinkedIn
44
45
  first and Email only. When your reported channels point to one, the list marks it
45
46
  `recommended: true` and returns a `recommendation` with its `reason`. A confirmed
@@ -83,8 +84,8 @@ field and the rules Audienti checks.
83
84
  Keep the same keys when you revise, so progress already recorded stays on its stage.
84
85
  2. `audienti playbook save --file REQUEST.json`. Over MCP use `playbook_save`.
85
86
  3. If the save fails, read the error, fix that field and retry with the same
86
- `requestId`. If it reports a revision conflict, run `playbook show ID` and apply your
87
- change to the newer revision.
87
+ `requestId`. If it reports a revision conflict, the error gives `currentRevision`: run
88
+ `playbook show ID`, apply your change to that revision and save with that `expectedRevision`.
88
89
 
89
90
  ## 4. Ask the user and pin it
90
91
 
@@ -18,7 +18,7 @@
18
18
  | `goalKey` | The `key` of the goal stage. Not the first stage. |
19
19
  | `offRamps` | Up to 30 ways out: `{ key, name, reason }`. `reason` is `rejected`, `not_fit`, `nurture`, `unresponsive`, `taken_over`, `bounced`, `skipped` or `other`. "Bad data" and "repair" are not off-ramps. |
20
20
  | `voice` | How messages should sound, in the user's style. |
21
- | `rules` | Optional: `autoMove`, `flagOverdue`, `allowBackward` (booleans), `emailWindow` (`weekdays_morning`, `weekdays`, `any_day`), `nurtureDays` (60, 90, 180 or null). |
21
+ | `rules` | Optional: `flagOverdue`, `allowBackward` (booleans), `emailWindow` (`weekdays_morning`, `weekdays`, `any_day`), `nurtureDays` (60, 90, 180 or null). Approval of a move is a setting on the account, not a playbook rule. An `autoMove` in an older playbook is ignored. |
22
22
 
23
23
  ## Stage
24
24
 
@@ -40,7 +40,7 @@
40
40
  | Field | Rule |
41
41
  | --- | --- |
42
42
  | `key` | A UUID, unique in the playbook. A plan action that runs this step carries it as `playbookStepKey`; actions without it change no playbook progress. |
43
- | `channel` | `LinkedIn`, `Email` or `Phone`. |
43
+ | `channel` | `LinkedIn` or `Email`. |
44
44
  | `action` | A short label, for example "Send connection request". |
45
45
  | `condition` | `Always`, `Only if viable` or `Only if connected`. Audienti does not check a connection itself: put an `Only if connected` step in a stage that opens on `accept`, so entering the stage on the accept is what gates it. |
46
46
  | `viability` | Required with `Only if viable`: when the step makes sense. An Email step is skipped, without a card, for a person with no email on record, whatever its condition. |
@@ -48,8 +48,10 @@
48
48
  | `wait` | Optional: `{ "until": U, "days": N }`. `until` is `accept` (LinkedIn steps only), `reply`, `meeting` or `timeout`; `days` is 1 to 60. The wait starts when the agent reports the step's result with its step key: a plan action carries the step's `key` as `playbookStepKey`, and a succeeded result for that action starts the wait. A reply or a booked meeting ends every wait early; an accept ends only a wait until `accept`. Older playbooks used `waitDays: N`, which still reads as `{ "until": "timeout", "days": N }`. |
49
49
  | `side` | Defaults to false. A side step runs while the main step before it waits, for example a profile view or a like. A stage with side steps needs a main step. |
50
50
  | `onTimeout` | `next` (default) or `off_ramp`. With `off_ramp`, a main step whose wait runs out leaves the prospect through the `unresponsive` off-ramp. When nothing is left in the stage, its completion decides. |
51
+ | `routes` | Optional: `{ "accept"?: T, "reply"?: T, "meeting"?: T }`, where `T` is `{ "kind": "stage", "stageKey": "<uuid>" }` or `{ "kind": "off_ramp", "offRampKey": "<uuid>" }`. Where an event that reaches the person after this step ran sends them. The step's route wins over the stage's `on` list; an event with no route follows the stage. A main step only; `accept` only on a LinkedIn step. A route to an earlier stage needs `rules.allowBackward`. |
52
+ | `actionId` | Optional: an action id from the outreach rulebook, for example `linkedin.message.send`. The `action` text stays the label the person reads. |
51
53
 
52
- A LinkedIn step whose action names InMail runs only when the LinkedIn account reports InMail available. Otherwise it is skipped without a card.
54
+ A LinkedIn step whose `actionId` is `linkedin.inmail.send` runs only when the LinkedIn account reports InMail available. Otherwise it is skipped without a card.
53
55
 
54
56
  ## Example request
55
57
 
@@ -83,7 +85,7 @@ Use the same UUID wherever the same placeholder letter appears.
83
85
  "goalKey": "<F>",
84
86
  "offRamps": [{ "key": "<R>", "name": "Unresponsive", "reason": "unresponsive" }],
85
87
  "voice": "Write like a founder. Lead with their signal, offer something useful, and ask one short question.",
86
- "rules": { "autoMove": true, "flagOverdue": true, "allowBackward": false, "emailWindow": "weekdays_morning", "nurtureDays": 90 }
88
+ "rules": { "flagOverdue": true, "allowBackward": false, "emailWindow": "weekdays_morning", "nurtureDays": 90 }
87
89
  }
88
90
  }
89
91
  ```
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "repository": "https://github.com/omalab/exora",
3
- "sourceCommit": "213901acb79c89cc1b217fc723f843355358b7c6",
3
+ "sourceCommit": "fd3e216215ce86b076aaa883754b54112519f45a",
4
4
  "files": {
5
5
  "LICENSE": "220f297cfcd5c38eb64e6b4b4a77cab4ebc7f4686d4d99c6260a475d02ca11e5",
6
6
  "README.md": "7ff32cc8ac89dd54b64d7b39c0b41b1f37e154b7ea10ad5a7bceefc3df77d87f",
@@ -8,13 +8,15 @@
8
8
  "cli/account-intake-operations.mjs": "b43d764c628beabafad753d711ddb2af2c64276e4a6506ed0b854839091ea5db",
9
9
  "cli/account-intake.mjs": "aa77f2f96fd030f8830d6de278932186aea7c82cfda086a5a6cf569202feebc8",
10
10
  "cli/account-replies.mjs": "699e34e5c736ba316df8f164b4c50f82a7e4e2d5e51303e6b13944c3f2c854ab",
11
- "cli/account-reply-operations.mjs": "276b9d7adde52c981adbf82a1ba304b9129b47944385cdedad7221c439718a05",
12
- "cli/agents.mjs": "6236de045344044d66c48c0d7a5c78a3fc31aa5af73a15477b2140ddc3d860c4",
11
+ "cli/account-reply-operations.mjs": "4359096dbf75d8c3602266588d2aea2b6cdecd903e2a094dbf25d26e2fab22b2",
12
+ "cli/agents.mjs": "8d0d79ceb2308cc0382e17025b5d8bc3b3c4e1b16215f309134635f19cd2fb84",
13
13
  "cli/assignment-operations.mjs": "fc74e3fd79975cfcbac14891d5dec5f25e662777050fc46a8ad3aae5cf361c59",
14
14
  "cli/assignment.mjs": "90cb85e0df8aadca2ff8391c99e778b41261183b5f38e8c6c32afee2f98f871a",
15
+ "cli/attachment-operations.mjs": "f8af683d0f351554e545c4e550870d2925204f896ecf62720b8013d74799bf67",
16
+ "cli/attachments.mjs": "2fee6b4abbf82da9847d4a927a3d4d3ca947a198a903a78927596a2ea858cbf4",
15
17
  "cli/audience-operations.mjs": "133b72dc6b9e3a6b4f173ce28f1524163f9c7b049505b885115f83b9e4af7170",
16
18
  "cli/audiences.mjs": "3c377346efc1a8bdde432a16c2d19a3f51f34f3333d344d3a2bfa94c188a017d",
17
- "cli/audienti.mjs": "7a2ed9483bb971f09d156e43c8bf68e38d3698baf22eea171910b24672954ec6",
19
+ "cli/audienti.mjs": "8b001322ba573f148a4fef3ee0918ee8a974bbedaf7a0fb497d53055729b5280",
18
20
  "cli/audit-operations.mjs": "184eb58bc479225314b77d6019a185670cd843895a10d9bf58a28de7f1355d91",
19
21
  "cli/audit.mjs": "a7becaed67c58b063e668b5e66844b6dc15c11a63b428faa171b542ad922c001",
20
22
  "cli/budget-operations.mjs": "d74916551ef393e29734c6e13cf9021251e0ed126eeaa529e18cf2332146e1e0",
@@ -41,12 +43,13 @@
41
43
  "cli/offers.mjs": "4a97007e1e25352707bbecda0b2519affcd0024e52ae8d9446d76d459460feca",
42
44
  "cli/outbound-operations.mjs": "52992bea63195efa8150c6ca2ff0b37ee6010f03fa366055c70c755ee0eb028e",
43
45
  "cli/outbound.mjs": "23903eb0fec4d7af3aa0031d63488ed9a8697e07ccf1314d194b48e109d15f82",
44
- "cli/outcome-operations.mjs": "d833c718be9e6877c122e95596bc6060a9796a24a991c62be4a28a2e1e531398",
46
+ "cli/outcome-operations.mjs": "25380db262c316ea1b34a5ae95abd01f2c8e92b7c877ebe3f0b18ccd5032a7fa",
45
47
  "cli/outcomes.mjs": "e547aa889c3d356c66d122d8fe4fd8f4ff46b18531c1318ca6f238af102cdde9",
46
48
  "cli/owner-account-client.mjs": "beb9736ac84a7ab57220ac1f1ea089b6401cf92fcf7c549417014d285f2340dc",
47
- "cli/playbook-operations.mjs": "25c593a2276b06c7e35377e3f132e6eee15c0e34542df62f1cf37a339d4e4ad2",
49
+ "cli/playbook-help.mjs": "6e1333d5dd3efc8b4fd85825f51339db198594ebe29dfb95777e969aa84a9459",
50
+ "cli/playbook-operations.mjs": "a60e24cbf784a150934cea2dcf36eac06b69def31d4b3151050bf222f3961cff",
48
51
  "cli/profiles.mjs": "45929b224ffc916b6dbd7a94e06ac09a1ff7feec430633b10d160c1608fc2d32",
49
- "cli/progress.mjs": "5d0f6b6094f212fb80aef0716e0917cf177eb2349490421784acc6f7cb576c44",
52
+ "cli/progress.mjs": "9350bf3d03ffd18bf5493b5bb012841b57624eae96d80aeb2e9088bf1efec5af",
50
53
  "cli/prospecting-settings-operations.mjs": "37b881a867c19d9e5f2d02316038284ea0cf09de4f37b6dee2370a1faedf245e",
51
54
  "cli/prospecting-settings.mjs": "b09f47c84416d9892d7d5b17dcf684cbb560e1cacb25513826ff04ef9307626e",
52
55
  "cli/question-operations.mjs": "5d7a885f2d4e0d2ce2249aadce2b4478b95843d21761f25eabdfe2759582a3b6",
@@ -55,12 +58,12 @@
55
58
  "cli/queue.mjs": "046f4dba92ac9eacbb1f12215ab2b49623bae597342da6b415f7afb3c3a1344e",
56
59
  "cli/recipient-operations.mjs": "7d41c17ef2d59dbed982893e0989c9c076317b91adcd3d752cbe1d2e0b8c41b2",
57
60
  "cli/recipients.mjs": "dc93d4a23ca1df9c84a0c9734d09491af179644eed48d856d1f1a24d42276ea8",
58
- "cli/rooms-operations.mjs": "09d36003183eccf2f6d2ee1ed6c4981519b8ae34fbffd0bb1a2234a8fc6cde68",
59
- "cli/rooms.mjs": "7fd05b4cead1bab2e18bf724c56034e12b4304f55cc69e3fc7ea5c4536e5305c",
61
+ "cli/rooms-operations.mjs": "27e4c1c0dac7cf1256468bac5806fb2a3959d07d3cac3af8d785a50772148fd1",
62
+ "cli/rooms.mjs": "bc42cc3f040b23bd832c725cb0e2faf66fe6327cbc64571550586ce97ba40415",
60
63
  "cli/search-operations.mjs": "903f12fffb6824872160c4136925ab5638142bb4e39236b37f927cd2c6a43776",
61
64
  "cli/secret-operations.mjs": "faefe9c9ece81087dc7aab32e88f331ed942d0757ce5a978b5f0722d514f377f",
62
65
  "cli/secrets.mjs": "cab4ea932e724ce3c807c7825869810b44fb8d75cdacec32f8101353f0e7c5f5",
63
- "cli/sender-account-operations.mjs": "3f5bc6b047916019a9266feb64041a7a338da94308910aa90eed6cb33e143ed0",
66
+ "cli/sender-account-operations.mjs": "97d7a9236c2b72e9863d3d75ab93671348ff97a07d627a3e72350a17623b4d4d",
64
67
  "cli/sender-accounts.mjs": "0c9be3e441aebf4bea2d2674ee54c4b17ed246c7a8476bcb4debbdad7cc1ec10",
65
68
  "cli/skills-mcp.mjs": "6a91b16a789f50035ae0fe33f8ae2758bb9829949db7efb05c6d65dde335a9e0",
66
69
  "cli/skills.mjs": "ea8dfef32664714f9c63251d9a6bbdb44d77d41e272df46bb102ebfcd8df8b66",
@@ -74,12 +77,12 @@
74
77
  "cli/work-execution.mjs": "4b74923417eaa70dee0ba8806b64d0acf7c884fb77b76d5e86d567cf92e51b01",
75
78
  "cli/work-feedback-operations.mjs": "a998f2b2dbe38ceb2bad74f7fa61c31f87f842755449248b7eb0c04185c0ee3d",
76
79
  "cli/work-feedback.mjs": "f3b77ecfd7ea1aba9c9f03c9d894137547e9c5575d9daa359ed94505ad025852",
77
- "cli/work-operations.mjs": "addcfcd5049168e27d224ff879e9fcccd146228e876bcae25c9dd7cfeaa3c9a9",
80
+ "cli/work-operations.mjs": "8b3a84ca15e8e4ad79ef92ced4c5adaffd5129f630e3bfc2c74a844a90867600",
78
81
  "cli/work.mjs": "751107ecc3de5a6be9db4a068cddc41f3ad8e77c87e59686eaf522862caf9e9f",
79
- "cli/workspaces.mjs": "09ea1fb6a416ef4810a72eab2b8bbe0fe7271e1dfe4ba29a214f31afd1af9707",
80
- "package.json": "790a0840cdb4991f42dcc9883085932e4864b3001aa365b6644de1cce690be57",
81
- "skills/agent-rooms/SKILL.md": "3da993292bf50f1220d2d724d74b5f46af8c8f5f054ce18bb7cf27ef29d0437d",
82
- "skills/agent-rooms/references/commands.md": "7cff1ecefd74a838146344ad928b3936dafc8eb14f6747ec02c0215f207818e9",
82
+ "cli/workspaces.mjs": "fc44a0df664f79d3e6fb84d0837f30ae20f01f0951f59e5e99ce116f6e76feef",
83
+ "package.json": "16d565964cf7d4a5f8d6c45bafa1e6d68888c9f4f2a389bbd507ffdb2546e0fa",
84
+ "skills/agent-rooms/SKILL.md": "08f917ff85fee1d23499a4499056ce845b39ce6727029b104819dad41ee87cc2",
85
+ "skills/agent-rooms/references/commands.md": "ef5afa5543ca9da967a147f638774647998ea1c1a2a821959f3bf87d54c292bd",
83
86
  "skills/audienti/SKILL.md": "8f2124065bfc3a005b44e194e7dcce9bbf14062ab332e7db66d441eeccb518ed",
84
87
  "skills/audienti/references/helpers.md": "e27aff0aa07b92f575d617612ed3def824765bac6ce1304cf227a4aca223369a",
85
88
  "skills/audienti/references/profile-workflows.md": "80669b75eb5e8aeb614ce55ac046236d671af0de3ddd238853324579d5a3b251",
@@ -90,12 +93,12 @@
90
93
  "skills/getting-started/SKILL.md": "902fffe321a91acd2dc3583a372801df6952c7e974179198ce688b62ca8b8f21",
91
94
  "skills/getting-started/references/audience.md": "6a2c061be5e299b3cbbd236578693ae5084d216425c500d3639a4d1fcb86ccb7",
92
95
  "skills/getting-started/references/campaign-draft.md": "c2cbeb7de95734889698a5cd852268b9bed69e56f661153bb7ada237241a1a6b",
93
- "skills/getting-started/references/capabilities.md": "67dc33dc6e3f5a73d77cfc14055e330b58c8f03c7a8268f7b5baa97f65e667bd",
96
+ "skills/getting-started/references/capabilities.md": "b41ab2ec511db127c8e43c80c71aa4fd516ca816af393f5a1c7b3456e355af96",
94
97
  "skills/getting-started/references/mailbox-check.md": "113574bb5a573125620dcde4e65ebf44164f7b0727cc936fb41234f5d57bba73",
95
- "skills/getting-started/references/outcome-checks.md": "05b2431b5f89bf05026ee4e5bac3ca8df594be97640eaca6a5e38907eca6e3dd",
96
- "skills/getting-started/references/replies.md": "019247b3eb260b21bf66689409748a9bb140d768fd0470a73992162fa8a5efd2",
98
+ "skills/getting-started/references/outcome-checks.md": "53919f2c2adf2bd24e602f4c2a21fbd47c807b115b2a1f9bb7893ebf48dc715a",
99
+ "skills/getting-started/references/replies.md": "edb9c0d64749f0fe86c3e054b438fcb2248a0ef6c666b95e470a16e2f5dce275",
97
100
  "skills/getting-started/references/reply-checks.md": "85fe692ad6b7bc73e204de313b27d225be3da1a0af71e8e7bb9945ae511a0dc3",
98
- "skills/playbook-design/SKILL.md": "a012c55740f59b662f8ec75ba6d3dd37a8d5fc9fb3a17806366823243cdc47ec",
99
- "skills/playbook-design/references/fields.md": "c9a5ee3dedae84193f6bd20e7f16b8c0f6ec55f1fc9620a032786dad66882e06"
101
+ "skills/playbook-design/SKILL.md": "551f10bd07541056521950930a5d8eaa332f5e9d742d157d7d0e01beff6c4460",
102
+ "skills/playbook-design/references/fields.md": "0082c96c1059bf4204b51fe625c03a130e141e4bf301e3205f06a544f6419b69"
100
103
  }
101
104
  }