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

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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/cli/account-reply-operations.mjs +24 -1
  3. package/cli/agents.mjs +42 -10
  4. package/cli/audience-operations.mjs +1 -1
  5. package/cli/audienti.mjs +54 -17
  6. package/cli/campaign-find.mjs +155 -0
  7. package/cli/campaign-operations.mjs +6 -3
  8. package/cli/campaigns.mjs +21 -3
  9. package/cli/cohort-operations.mjs +1 -1
  10. package/cli/discovery-operations.mjs +2 -1
  11. package/cli/discovery.mjs +3 -2
  12. package/cli/json-file.mjs +1 -1
  13. package/cli/kv-operations.mjs +17 -0
  14. package/cli/kv.mjs +58 -0
  15. package/cli/mcp.mjs +2 -2
  16. package/cli/outcome-operations.mjs +19 -0
  17. package/cli/outcomes.mjs +16 -0
  18. package/cli/owner-account-client.mjs +1 -1
  19. package/cli/{approach-operations.mjs → playbook-operations.mjs} +15 -15
  20. package/cli/progress.mjs +6 -6
  21. package/cli/question-operations.mjs +4 -4
  22. package/cli/questions.mjs +4 -4
  23. package/cli/queue-operations.mjs +7 -6
  24. package/cli/queue.mjs +4 -3
  25. package/cli/recipient-operations.mjs +1 -1
  26. package/cli/rooms-operations.mjs +6 -5
  27. package/cli/rooms.mjs +7 -6
  28. package/cli/secret-operations.mjs +7 -0
  29. package/cli/secrets.mjs +30 -0
  30. package/cli/sender-account-operations.mjs +6 -2
  31. package/cli/sender-accounts.mjs +13 -8
  32. package/cli/skills-mcp.mjs +6 -2
  33. package/cli/skills.mjs +55 -12
  34. package/cli/tools-operations.mjs +18 -10
  35. package/cli/tools.mjs +48 -20
  36. package/cli/work-execution-operations.mjs +6 -0
  37. package/cli/work-execution.mjs +3 -3
  38. package/cli/work-operations.mjs +4 -3
  39. package/cli/work.mjs +2 -2
  40. package/cli/workspaces.mjs +28 -16
  41. package/package.json +2 -2
  42. package/skills/agent-rooms/SKILL.md +104 -0
  43. package/skills/agent-rooms/references/commands.md +58 -0
  44. package/skills/audienti/SKILL.md +106 -5
  45. package/skills/audienti/references/helpers.md +15 -2
  46. package/skills/audienti/references/profile-workflows.md +4 -2
  47. package/skills/audienti/scripts/contracts.mjs +1 -1
  48. package/skills/getting-started/SKILL.md +180 -154
  49. package/skills/getting-started/references/audience.md +72 -7
  50. package/skills/getting-started/references/campaign-draft.md +54 -13
  51. package/skills/getting-started/references/capabilities.md +214 -0
  52. package/skills/getting-started/references/mailbox-check.md +64 -0
  53. package/skills/getting-started/references/outcome-checks.md +89 -0
  54. package/skills/getting-started/references/replies.md +152 -0
  55. package/skills/getting-started/references/reply-checks.md +54 -0
  56. package/skills/playbook-design/SKILL.md +132 -0
  57. package/skills/playbook-design/references/fields.md +89 -0
  58. package/source-manifest.json +58 -43
  59. package/skills/getting-started/references/inventory.md +0 -52
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # Audienti CLI for Exora
2
2
 
3
- Use Node 24. This release connects agents to **https://exo.audienti.com**.
3
+ Use Node 24. This release connects agents to **https://app.audienti.com**.
4
4
  The `exora` npm tag selects this client; the legacy `latest` tag is unchanged.
5
5
 
6
6
  ```sh
7
7
  npm install -g @audienti/cli@exora
8
- export AUDIENTI_API_URL=https://exo.audienti.com/api/v1
8
+ export AUDIENTI_API_URL=https://api.audienti.com/api/v1
9
9
  audienti agent connect --name "My agent" --profile "$HOME/.audienti/exo-agent.json"
10
10
  ```
11
11
 
@@ -5,6 +5,24 @@ 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']);
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
+ const draft = z.object({
11
+ type: z.enum(['email.send', 'linkedin.send']),
12
+ content: z.record(z.string(), z.unknown()).describe('Email: { subject, body }. LinkedIn: { body }.'),
13
+ recipient: z.record(z.string(), z.unknown()).describe('Email: { prospectId, email, contactId? }. LinkedIn: { prospectId, profileUrl, contactId? }. Bind contactId and senderAccountId, or the answer waits in Blocked.'),
14
+ sender: z.record(z.string(), z.unknown()).describe("{ provider, accountRef, senderAccountId? }: the assignee's mailbox or LinkedIn account the inbound came to."),
15
+ reason: z.string().describe('Why this answer, in one or two sentences.'),
16
+ slots: z.array(slot).min(1).max(6).optional().describe('Free times from your calendar tool that the answer offers. Leave out without a calendar; the answer then asks for a time.'),
17
+ skill: z.object({ id: z.string(), version: z.string() }).strict().optional(),
18
+ }).strict();
19
+ // Brief 12: what a stop, bounce or referral means, as a typed detail next to the classification it matches.
20
+ const quote = z.string().meta({ minLength: 1, maxLength: 500 }).describe("The prospect's words, verbatim.");
21
+ const result = z.discriminatedUnion('type', [
22
+ z.object({ type: z.literal('email.bounce'), address: z.string().meta({ minLength: 3, maxLength: 320 }).describe('The address that bounced.'), kind: z.enum(['hard', 'soft']).describe('hard: the address does not exist or refuses mail. soft: a full mailbox or a temporary failure.'), evidence: z.string().meta({ minLength: 1, maxLength: 2000 }).describe('The bounce message, its status code and diagnostic.') }).strict().describe('Classification bounce. A hard bounce, or a second bounce, marks the address bounced and blocks it in every campaign.'),
23
+ z.object({ type: z.literal('reply.stop'), scope: z.enum(['person', 'company']).describe('company only when the prospect asked to stop for their whole company.'), quote }).strict().describe('Classification stop, with status handled and outcome suppressed. The person, or everyone at the company, is suppressed for good.'),
24
+ z.object({ type: z.literal('reply.referral'), name: z.string().meta({ minLength: 1, maxLength: 200 }).describe('The person the prospect named.'), title: z.string().meta({ maxLength: 200 }).optional(), email: z.string().meta({ maxLength: 320 }).optional(), linkedinUrl: z.string().meta({ maxLength: 500 }).optional(), quote }).strict().describe('Classification referral, with status needs_handling and the thank-you as the draft. The named person is checked against the audience before any contact.'),
25
+ ]);
8
26
  const root = a => `/workspaces/${a.workspaceId}/prospects/${a.prospectId}`;
9
27
  const detail = a => `${root(a)}/conversation-evidence/${a.evidenceId}/reply`;
10
28
  const query = (path, a, keys) => {
@@ -15,7 +33,12 @@ const query = (path, a, keys) => {
15
33
  export const accountReplyOperations = {
16
34
  workspace_reply_set: {
17
35
  schema: z.object({ ...item, requestId: uuid, expectedVersion: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), status,
18
- 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.") }).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."),
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."),
38
+ 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
+ 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
+ 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."),
19
42
  method: 'POST', scoped: true, path: detail,
20
43
  body: ({ workspaceId, prospectId, evidenceId, ...rest }) => rest,
21
44
  },
package/cli/agents.mjs CHANGED
@@ -4,9 +4,18 @@ 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
6
  const inventoryLabel=z.string().trim().min(1).max(80);
7
+ // Capability rows (ADR 015). The server checks ids, providers and access against the catalog; the client checks the shape.
8
+ 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
+ // A LinkedIn seat's cadence, InMail and safe limits (brief 09); the server checks the bounds.
10
+ 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()};
12
+ const accountEntry=z.object({capability:z.string(),tools:z.array(z.string()).min(1).max(8)}).strict();
13
+ 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
+ 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();
15
+ const eitherReport=z.union([capabilityReport,accountReport]);
7
16
  const inventoryEntry=z.object({category:z.enum(['web_search','web_browse','people_data','company_data','email','linkedin','social','crm','spreadsheet','docs','chat','calendar','tasks','files','code','scheduling','memory','other']),name:inventoryLabel,access:z.array(z.enum(['read','write','send'])).min(1).max(3),status:z.enum(['connected','needs_sign_in','unavailable']),via:inventoryLabel.optional(),tools:z.array(z.string().min(1).max(128)).max(16).optional()}).strict();
8
17
  export const agentOperations = {
9
- agent_inventory_report:{description:'Getting started step 2: report the tools and connectors this agent already has (search, mailboxes, CRM, sheets, chat, scheduling, memory). Replaces the whole inventory. List only what is already available; never connect anything new and never include credentials. Requires an active agent profile.',schema:z.object({runtime:z.object({name:inventoryLabel,model:inventoryLabel.optional()}).strict(),entries:z.array(inventoryEntry).max(64)}).strict()},
18
+ agent_inventory_report:{description:'Older shape, kept for one release; use agent_capabilities_report instead. Reports the tools and connectors this agent already has (search, mailboxes, CRM, sheets, chat, scheduling, memory). Replaces the whole inventory. List only what is already available; never connect anything new and never include credentials. Requires an active agent profile.',schema:z.object({runtime:z.object({name:inventoryLabel,model:inventoryLabel.optional()}).strict(),entries:z.array(inventoryEntry).max(64)}).strict()},
10
19
  agent_inventory_show:{description:'Read this agent\u2019s own reported tool inventory. Requires an active agent profile.',schema:z.object({}).strict()},
11
20
  agent_inventory_get:{schema:z.object({id:z.string().uuid()}).strict()},
12
21
  agent_account_authorizations_get:{schema:z.object({id:z.string().uuid()}).strict()},
@@ -14,7 +23,9 @@ export const agentOperations = {
14
23
  agent_account_authorization_set:{schema:z.object({id:z.string().uuid(),provider:z.enum(['email','linkedin']),accountRef:z.string().min(1).max(200),authorized:z.boolean()}).strict()},
15
24
  agent_capability_sharing_get: {schema:z.object({id:z.string().uuid()}).strict()},
16
25
  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()},
17
- agent_capabilities_report: {schema: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(z.object({capability:z.string(),tools:z.array(z.string()).min(1).max(8)}).strict()).max(16)}).strict()},
26
+ // 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},
28
+ 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()},
18
29
  agent_capabilities_show: {schema:z.object({}).strict()},
19
30
  agent_capabilities_get: {schema:z.object({id:z.string().uuid()}).strict()},
20
31
  agent_permission: {schema:z.object({id:z.string().uuid(),scope:z.enum(['workspace:read','workspace:access'])}).strict()},
@@ -33,14 +44,17 @@ async function request(config, path, body, authenticated = false, method = body
33
44
  let result; try { result = await response.json(); } catch { throw new Error('The API returned an invalid response.'); }
34
45
  if (!response.ok) {
35
46
  const code = typeof result?.error?.code === 'string' && /^[a-z_]+$/.test(result.error.code) ? result.error.code : 'request_failed';
36
- throw new WorkspaceRequestError(response.status, code, `Agent request failed: ${code} (HTTP ${response.status}).`);
47
+ // A refused report says why (ADR 015), with the same safety checks as the workspace client.
48
+ const message = result?.error?.message;
49
+ const detail = response.status === 400 && (code === 'validation_error' || code === 'hosted_via_reserved') && typeof message === 'string' && message.length <= 300 && !/[\u0000-\u001f\u007f]/.test(message) && !(config.token && message.includes(config.token)) ? ` ${message}` : '';
50
+ throw new WorkspaceRequestError(response.status, code, `Agent request failed: ${code} (HTTP ${response.status}).${detail}`);
37
51
  }
38
52
  if (!result || !Object.hasOwn(result, 'data')) throw new Error('The API returned an invalid response.');
39
53
  return result.data;
40
54
  }
41
55
  export async function callAgent(name, args, config, profilePath) {
42
56
  const input = agentOperations[name]?.schema.safeParse(args);
43
- if (!input?.success) throw new Error('Invalid agent command or arguments.');
57
+ if (!input?.success || (agentOperations[name].shape && !agentOperations[name].shape.safeParse(args).success)) throw new Error('Invalid agent command or arguments.');
44
58
  if (agentManagementOperations.includes(name) && (config.profile?.agent || config.profile?.pendingAgent)) throw new Error('Use a separate user management profile with an agents:manage token.');
45
59
  if(name==='agent_account_authorizations_get') return {data:await request(config,`/agents/${input.data.id}/account-authorizations`,undefined,true)};
46
60
  if(name==='agent_account_authorization_set') {const {id,...body}=input.data;return {data:await request(config,`/agents/${id}/account-authorizations`,body,true)};}
@@ -56,6 +70,11 @@ export async function callAgent(name, args, config, profilePath) {
56
70
  return {data:await request(config,'/agent-inventory',name==='agent_inventory_report' ? input.data : undefined,true,name==='agent_inventory_report' ? 'PUT' : 'GET')};
57
71
  }
58
72
  if (name === 'agent_capabilities_get') return {data:await request(config,`/agents/${input.data.id}/capabilities`,undefined,true)};
73
+ if (name === 'agent_capabilities_list') {
74
+ if (!config.profile?.agent || !/^agt_/.test(config.token ?? '')) throw new Error('Use an active agent profile for capability check-in and self readback.');
75
+ const workspaceId = input.data.workspaceId ?? config.workspaceId;
76
+ return {data:await request(config,`/agent-capabilities${workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''}`,undefined,true)};
77
+ }
59
78
  if (name === 'agent_capabilities_show' || name === 'agent_capabilities_report') {
60
79
  if (!config.profile?.agent || !/^agt_/.test(config.token ?? '')) throw new Error('Use an active agent profile for capability check-in and self readback.');
61
80
  return {data:await request(config,'/agent-capabilities',name==='agent_capabilities_report' ? input.data : undefined,true,name==='agent_capabilities_report' ? 'PUT' : 'GET')};
@@ -76,7 +95,7 @@ export async function callAgent(name, args, config, profilePath) {
76
95
  if (!/^adc_[A-Za-z0-9_-]{43}$/.test(data.secret) || !/^agt_[A-Za-z0-9_-]{43}$/.test(data.credential) || !z.string().uuid().safeParse(data.id).success || !/^[A-F0-9]{4}-[A-F0-9]{4}$/.test(data.code)) throw new Error('Invalid connection response.');
77
96
  writeProfile(profilePath, { ...profile, url, pendingAgent: { id: data.id, secret: data.secret, credential: data.credential, expiresAt: data.expiresAt } });
78
97
  config.token = undefined; config.workspaceId = undefined;
79
- return { data: { status: 'pending', id: data.id, code: data.code, approvalUrl: data.approvalUrl, approvalPath: `/agents/connect/${data.id}`, expiresAt: data.expiresAt, interval: 5, instructions: 'Open the approval URL, sign in, enter the matching code and approve. Then run agent poll with this profile.' } };
98
+ return { data: { status: 'pending', id: data.id, code: data.code, approvalUrl: data.approvalUrl, approvalPath: `/agents/connect/${data.id}`, expiresAt: data.expiresAt, interval: 5, instructions: 'Give your user the approval URL and the code, then run agent poll with this profile every interval seconds, in this same turn and without waiting for them to reply, until the status is active, denied or expired.' } };
80
99
  }
81
100
  const secret = profile.pendingAgent?.secret ?? profile.connectionSecret;
82
101
  if (!secret) throw new Error('No connection in this profile.');
@@ -90,9 +109,9 @@ export async function callAgent(name, args, config, profilePath) {
90
109
  return { data };
91
110
  }
92
111
  export async function agentCli(args) {
93
- const flags = {}; const positions = [];
112
+ const flags = {}; const positions = []; let json = false;
94
113
  for (let i = 0; i < args.length; i++) {
95
- if (args[i] === '--json') continue;
114
+ if (args[i] === '--json') { json = true; continue; }
96
115
  if (args[i].startsWith('--')) {
97
116
  const key = args[i].slice(2);
98
117
  if (!['profile', 'name','capabilities','scope','file','id','provider','account','workspace','shared','authorized','set'].includes(key) || !args[i+1] || args[i+1].startsWith('--')) throw new Error('Unknown or incomplete agent option.');
@@ -107,7 +126,7 @@ export async function agentCli(args) {
107
126
  else throw new Error('Use agent accounts show [--id ID] or authorize --id ID --provider PROVIDER --account REF --authorized true|false.');
108
127
  }
109
128
  if (positions[1] === 'inventory') {
110
- if (positions[2] === 'report' && flags.file && !flags.id) {name='agent_inventory_report';input=readJsonFile(flags.file,32768,'Inventory file');}
129
+ if (positions[2] === 'report' && flags.file && !flags.id) {name='agent_inventory_report';input=readJsonFile(flags.file,32768,'Inventory file');console.error('Notice: agent capabilities report --file PATH is the current command; inventory reports are translated into capability rows.');}
111
130
  else if (positions[2] === 'show' && !flags.file) {name=flags.id ? 'agent_inventory_get' : 'agent_inventory_show';input=flags.id ? {id:flags.id} : {};}
112
131
  else throw new Error('Use agent inventory report --file PATH or show [--id ID].');
113
132
  }
@@ -117,9 +136,22 @@ export async function agentCli(args) {
117
136
  else if (positions[2] === 'share' && flags.id && ['true','false'].includes(flags.shared)) {name='agent_capability_sharing_set';input={id:flags.id,provider:flags.provider,accountRef:flags.account,workspaceId:flags.workspace,shared:flags.shared==='true'};}
118
137
  else if (positions[2] === 'report' && flags.file && !flags.id) {name='agent_capabilities_report';input=readCapabilityFile(flags.file);}
119
138
  else if (positions[2] === 'show' && !flags.file) {name=flags.id ? 'agent_capabilities_get' : 'agent_capabilities_show';input=flags.id ? {id:flags.id} : {};}
120
- else throw new Error('Use agent capabilities report --file PATH or show [--id ID].');
139
+ else if (positions[2] === undefined && !flags.file && !flags.id) {name='agent_capabilities_list';input=flags.workspace ? {workspaceId:flags.workspace} : {};}
140
+ else throw new Error('Use agent capabilities [--workspace ID] [--json], report --file PATH or show [--id ID].');
121
141
  }
122
- console.log(JSON.stringify(await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), flags.profile)));
142
+ const result = await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), flags.profile);
143
+ console.log(name === 'agent_capabilities_list' && !json ? capabilityText(result.data) : JSON.stringify(result));
144
+ }
145
+
146
+ /** Plain text for agent capabilities: own rows that work now, then rows that work with Audienti hosted tools. */
147
+ export function capabilityText(data) {
148
+ const rows = Array.isArray(data?.effective) ? data.effective : [];
149
+ const line = row => ` ${row.capability}${row.provider ? ` (${row.provider})` : ''} via ${row.via}${row.account ? `, account ${row.account}` : ''}${row.status === 'connected' ? '' : `, ${row.status.replaceAll('_', ' ')}`}${row.hostedTool ? `, ${row.hostedTool.id}${row.hostedTool.credits === undefined ? '' : ` about ${row.hostedTool.credits} credits a call`}` : ''}${row.stale ? ', stale' : ''}`;
150
+ const own = rows.filter(row => row.via !== 'audienti'), hosted = rows.filter(row => row.via === 'audienti');
151
+ return [
152
+ `Works now${data?.stale ? ' (report is over seven days old; report again)' : ''}:`, ...(own.length ? own.map(line) : [' nothing reported yet: audienti agent capabilities report --file CAPABILITIES.json']),
153
+ 'Works with Audienti tools:', ...(hosted.length ? hosted.map(line) : [data?.workspaceId ? ' none: the workspace has no credits or the user has not approved these groups' : ' pick a workspace to see them: --workspace ID']),
154
+ ].join('\n');
123
155
  }
124
156
 
125
157
  function readCapabilityFile(path) {return readJsonFile(path,32768,'Capability file');}
@@ -13,7 +13,7 @@ export const audienceOperations={
13
13
  audience_update:{schema:z.object({...locator,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>path(a)+`/${a.audienceId}/revisions`,body:a=>({expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
14
14
  audience_archive:{schema:z.object(locator).strict(),method:'POST',path:a=>path(a)+`/${a.audienceId}/archive`,scoped:true},
15
15
  audience_restore:{schema:z.object(locator).strict(),method:'POST',path:a=>path(a)+`/${a.audienceId}/restore`,scoped:true},
16
- audience_summary:{schema:z.object({...workspace,audienceId:uuid.optional()}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/audience-summary`+(a.audienceId?`?audienceId=${a.audienceId}`:''),scoped:true,description:'Counts of active and archived Audiences, the campaigns using each (with people, engaged and at-goal counts), and people found per Audience (recent means admitted in the last 7 days).'},
16
+ audience_summary:{schema:z.object({...workspace,audienceId:uuid.optional()}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/audience-summary`+(a.audienceId?`?audienceId=${a.audienceId}`:''),scoped:true,description:'Counts of active and archived Audiences, the campaigns using each (with people, engaged and at-goal counts), and people found per Audience (recent means admitted in the last 7 days). With audienceId, sizes gives that Audience\'s approximate reachable size against the 1,000-person minimum (null when it cannot be estimated; sizePending while the estimate runs).'},
17
17
  audience_people:{schema:z.object(locator).strict(),method:'GET',path:a=>path(a)+`/${a.audienceId}/people`,scoped:true,description:'People found by any campaign on this Audience, newest first, at most 100, with their latest participation.'},
18
18
  audience_companies:{schema:z.object(locator).strict(),method:'GET',path:a=>path(a)+`/${a.audienceId}/companies`,scoped:true,description:'Companies where this Audience has found people, most people first, at most 100; withoutCompany counts people with no company.'},
19
19
  audience_fit_check_improve:{schema:z.object({...workspace,text:z.string().trim().min(1).max(5000)}).strict(),method:'POST',path:a=>`/workspaces/${a.workspaceId}/audience-fit-checks`,body:a=>({text:a.text}),scoped:true,description:'Review a smart rule (gut check) and suggest a clearer rewrite a reviewer can judge from a profile. Returns whether it is usable, what counts as a match and a contradiction, and the rewrite. Saves nothing.'},
package/cli/audienti.mjs CHANGED
@@ -3,9 +3,10 @@ import { isAssignmentCommand, assignmentCli } from './assignment.mjs';
3
3
  // The CLI is an HTTP client. It has no database access or separate business rules.
4
4
  const args = process.argv.slice(2);
5
5
  const usage = `Profiles: audienti profiles list|search|get ID | match evaluate|apply SOURCE CANDIDATE | history RESOLUTION (saved workspace records)
6
- Skills: audienti skills list|show audienti|helper NAME --file INPUT.json|- [--json]
7
- Tools: audienti tools list|describe|decide|runs|balance [--help]
6
+ Skills: audienti skills list [--compact]|show SKILL|read SKILL RESOURCE|helper NAME --file INPUT.json|- [--json]
7
+ Tools: audienti call TOOL ['JSON'] | tools search "WORDS"|get TOOL|list|runs|balance [--help]
8
8
  Sender: audienti sender accounts list|get|alias|register|bind|unbind|state|automation|activity report [--profile PATH]
9
+ Managed LinkedIn: audienti sender managed list|connect [--profile PATH] (connect: the user only; returns a sign-in link)
9
10
  Prospecting: audienti prospecting settings show|save|accounts|account-show|account-save|history [--profile PATH]
10
11
  Usage: audienti whoami [--json]
11
12
  audienti workspace assignments list [--workspace ID]
@@ -26,58 +27,66 @@ Use HTTPS for remote hosts. Local HTTP is supported on loopback only.
26
27
  Agent commands: agent connect --name NAME [--capabilities find,enrich,listen,approve]|poll|cancel|list|revoke ID --profile PATH
27
28
  Owner permission change: agent permission ID --scope workspace:read|workspace:access --profile USER_PROFILE
28
29
  Owner capability change: agent capabilities ID --set find,enrich,listen,approve --profile USER_PROFILE (agents are refused outside this set)
29
- Agent capabilities: agent capabilities report --file REPORT.json --profile AGENT_PROFILE
30
+ Agent capabilities: agent capabilities report --file CAPABILITIES.json --profile AGENT_PROFILE (capability rows; the old per-account shape still works)
31
+ What the agent can do: agent capabilities [--workspace ID] [--json] --profile AGENT_PROFILE (works now, and works with Audienti tools)
30
32
  Read capabilities: agent capabilities show [--id AGENT_ID] --profile PROFILE
31
- Getting started: skills show getting-started, then agent inventory report --file INVENTORY.json --profile AGENT_PROFILE
32
- Read tool inventory: agent inventory show [--id AGENT_ID] --profile PROFILE
33
+ Getting started: skills show getting-started, then agent capabilities report --file CAPABILITIES.json --profile AGENT_PROFILE
34
+ Read tool inventory: agent inventory show [--id AGENT_ID] --profile PROFILE (agent inventory report still works; capabilities report is current)
33
35
  Account authorization: agent accounts show [--id AGENT_ID] --profile PROFILE
34
36
  Authorize/revoke: agent accounts authorize --id AGENT_ID --provider email|linkedin --account REF --authorized true|false --profile OWNER_PROFILE
35
37
  Sharing: agent capabilities sharing --id AGENT_ID --profile OWNER_PROFILE
36
38
  Share/unshare: agent capabilities share --id AGENT_ID --provider email|linkedin --account REF --workspace ID --shared true|false --profile OWNER_PROFILE
37
39
  Team member: workspace member USER_ID [--workspace ID] --profile PROFILE
38
- Work execution: work execution claim|start|get PLAN --action ID; release|lookup --claim ID; report --attempt ID
40
+ Work execution: work execution claim|start|send|get PLAN --action ID (send: managed LinkedIn accounts, --file with claimId and requestId); release|lookup --claim ID; report --attempt ID
39
41
  Work feedback: work changes request|list|get|respond|withdraw PLAN --workspace ID
40
- Work: work plan save|list|show|approve|withdraw-approval|cancel|eligibility
42
+ Work: work plan save|list|show|approve|withdraw-approval|cancel|skip|eligibility
41
43
  Activity: work activity --workspace ID [--search TEXT] [--cursor-at ISO --cursor-id ID]
42
44
  Work list: work list --workspace ID [--order oldest|priority] [--queue review|blocked|activity]
45
+ a plan that only waits for its cadence is in neither review nor blocked; it returns to review when the wait ends
43
46
  Work writes: --file INPUT.json; human review needs a human workspace:access token
44
- Audiences: audience options --kind industries|locations|sizes|types|seniorities|titles|functions|industry_groups [--q TEXT]
47
+ Audiences: audience options --kind industries|locations|sizes|types|seniorities|titles|functions [--q TEXT]
45
48
  audience list|show ID|create|update ID|archive ID|restore ID
46
49
  audience summary [--audience ID]|people ID|companies ID
47
50
  audience test ID --url LINKEDIN_URL --request-id UUID (spends hosted credits unless the profile is saved)
48
51
  Files: file upload PATH --type MIME|list|prepare|register|download|delete ID (personal files, human token only)
49
- Queue: audienti queue [list]|next|claim ID|done ID [--note TEXT]|skip ID --note TEXT|release ID (start here after you connect)
50
- Questions: audienti ask "QUESTION" [--context TEXT] [--option TEXT]... [--campaign ID] [--task ID] (questions for your owner go to Operator, not chat)
52
+ Queue: audienti queue [list]|next|claim ID [--note TEXT]|done ID [--note TEXT]|skip ID --note TEXT|release ID [--workspace ID] (start here after you connect; without --workspace an agent reads every workspace its user approved it in)
53
+ Outcome checks: audienti outcomes report TASK_ID --file RESULTS.json (results for the people an outcomes.check task lists)
54
+ Questions: audienti ask "QUESTION" [--context TEXT] [--option TEXT]... [--campaign ID] [--task ID] (questions for your user go to Operator, not chat)
51
55
  audienti questions [list] [--state open|answered|withdrawn|all] | show ID | withdraw ID | answer ID --answer TEXT
52
- Rooms: audienti rooms directory|inbox|ack|send|thread|threads|create|list|retire|restore|redact|resume|pause|profile|hook|watch|wait (audienti rooms help)
56
+ Rooms: audienti rooms directory|inbox|ack|send|thread|threads|create|list|read|retire|restore|redact|resume|pause|profile|hook|watch|wait (audienti rooms help)
57
+ KV: audienti kv get KEY|set KEY VALUE [--json] [--nx] [--if-version N] [--ttl 7d|--expires-at TIME]|list [--prefix P]|search [--key TEXT] [--value TEXT]|delete KEY (small state between runs; never credentials)
53
58
  Offers: offer list|show ID|create|update ID|archive ID|restore ID
54
59
  Offer options: --file FIELDS.json --request-id UUID --expected-revision N --revision N --archived true|false
55
60
  audienti prospects replies set|get|list PERSON --workspace ID [--evidence EVIDENCE] [--file INPUT.json]
56
61
  Account intake: prospects intake --workspace ID --file INPUT.json
57
62
  prospects conversation append|list PERSON --workspace ID
58
- Discovery: prospects list|get ID|observation create|citation create|evidence create
59
- prospects contacts list|record PERSON --workspace ID
63
+ Discovery: prospects list|get ID|timeline ID [--limit N]|observation create|citation create|evidence create
64
+ prospects contacts list|record PERSON [--file INPUT.json] --workspace ID
60
65
  prospects contacts get|retire PERSON CONTACT --workspace ID
61
66
  prospects suppression get|set ID --workspace ID
62
67
  guardrails show|pause|resume --workspace ID
63
68
  dnc list|add VALUE|remove VALUE --workspace ID
64
69
  dnc import --file LIST.csv --workspace ID (at most 5,000 rows; reports added, skipped and invalid rows)
65
70
  dnc export [--output FILE.csv] --workspace ID
71
+ secret list [--workspace ID] (names only)
72
+ secret get NAME [--workspace ID] [--json] (prints the value; every read is logged)
66
73
  budgets show --workspace ID
67
74
  budgets alerts --workspace ID [--limit N]
68
75
  usage breakdown [--workspace ID] [--since month|7d|30d|90d] [--by tool|provider|model|caller|day] [--format json|csv]
69
76
  audit list [--workspace ID] [--since 7d] [--actor agent|person] [--category NAME] [--format json|csv]
70
77
  budgets set --workspace ID [--agent ID --cap N|none] [--new-agent-cap N|none] [--at-limit pause|ask] [--alert80 on|off] (alert80 covers Operator and email at 80%)
71
78
  Qualification: campaigns qualification submit --file INPUT.json
72
- Approaches: approach list|show ID|save --file REQUEST.json|progress ID|record --file REQUEST.json
73
- Analytics: analytics show --from ISO --to ISO [--campaign ID] [--approach-version ID] [--format json|csv]
79
+ Find prospects: campaign find CAMPAIGN_ID --count N --max-credits C (searches with the audience filters, checks each person, admits only fits; stops at N fits or the credit cap and reports the spend)
80
+ campaign check CAMPAIGN_ID --url LINKEDIN_URL [--request-id UUID] (checks one person the same way; reuse the request ID after an interruption)
81
+ Playbooks: playbook list|show ID|save --file REQUEST.json|progress ID|record --file REQUEST.json
82
+ Analytics: analytics show --from ISO --to ISO [--campaign ID] [--playbook-version ID] [--format json|csv]
74
83
  analytics outbound [--workspace ID] [--days N | --from ISO --to ISO] [--by sender|action|day] [--format json|csv]
75
84
  analytics activity [--workspace ID] [--days N | --from ISO --to ISO] [--format json|csv]
76
85
  analytics cohorts [--workspace ID] [--stage goal|KEY] [--cohorts N] [--horizon N] [filters] [--format json|csv]
77
86
  analytics aging [--workspace ID] [--threshold-days N] [filters] [--format json|csv]
78
87
  analytics filters [--workspace ID] (lists campaign, owner, offer, audience and tag values)
79
88
  (filters: --campaign ID --owner ID --offer ID --audience ID --tag TAG; also accepted by analytics show)
80
- Pipeline: pipeline show [--campaign ID] [--approach-version ID]
89
+ Pipeline: pipeline show [--campaign ID] [--playbook-version ID]
81
90
  Connections: connections list|register --file REQUEST.json|report --file REQUEST.json
82
91
  Discovery writes use --file INPUT.json, --workspace ID and stable --request-id UUID
83
92
  Workspace commands: workspace init|list|create|show|search|invite|invitations|use
@@ -92,7 +101,30 @@ Start local MCP stdio server: mcp
92
101
  --json writes the API response envelope.
93
102
  `;
94
103
 
104
+ // Example inputs for the commands that save prospects, so `--help` shows a working shape.
105
+ const examples = {
106
+ 'prospects observation create': { requestId: 'UUID', kind: 'person', source: { provider: 'web', url: 'https://example.com/team/jane' }, fields: { name: 'Jane Doe', title: 'VP Sales', imageUrl: 'https://media.licdn.com/dms/image/PHOTO_ID' }, provenance: { url: 'https://example.com/team/jane' }, observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
107
+ 'prospects citation create': { requestId: 'UUID', source: { kind: 'prospect', id: 'PERSON_PROSPECT_ID' }, target: { kind: 'prospect', id: 'COMPANY_PROSPECT_ID' }, edgeType: 'works_at', roles: [{ roleId: 'role-1', title: 'VP Sales', current: true, observedAt: '2026-01-01T00:00:00Z' }] },
108
+ 'prospects evidence create': { requestId: 'UUID', subject: { kind: 'person', prospectId: 'PROSPECT_ID' }, campaignId: 'CAMPAIGN_ID', campaignRevision: 1, kind: 'interpretation', data: { conclusion: 'Leads a 40-person sales team', reason: 'Team page lists her as VP Sales' }, sourceUrl: 'https://example.com/team', observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
109
+ 'prospects contacts record': { kind: 'email|linkedin|mobile|phone', value: 'jane@example.com, or +15551234567 for a mobile or a direct office phone', reason: 'Published on https://example.com/contact', requestId: 'UUID', provenance: { observationId: 'OBSERVATION_ID' } },
110
+ 'campaigns qualification submit': { requestId: 'UUID', campaignId: 'CAMPAIGN_ID', campaignRevision: 1, prospectId: 'PERSON_PROSPECT_ID', companyProspectId: 'COMPANY_PROSPECT_ID', companyIcp: { status: 'pass|fail|unverified', evidenceIds: ['COMPANY_EVIDENCE_ID'] }, personIcp: { status: 'pass|fail|unverified', evidenceIds: ['PERSON_EVIDENCE_ID'] }, signal: { status: 'match|no_match|unverified|not_required', evidenceIds: [] }, fit: { status: 'high|moderate|low|no_fit|unscored', evidenceIds: ['PERSON_EVIDENCE_ID'], reason: 'why' }, skill: { id: 'signal-prospect-research', version: '1' } },
111
+ };
112
+
113
+ function commandHelp() {
114
+ const words = args.slice(0, args.findIndex(value => value.startsWith('-'))).filter(Boolean);
115
+ const lines = usage.split('\n');
116
+ let shown = [];
117
+ for (let n = Math.min(words.length, 2); n > 0 && shown.length === 0; n--) {
118
+ const prefix = words.slice(0, n).join(' ');
119
+ shown = lines.filter(line => line.includes(prefix));
120
+ }
121
+ console.log(shown.length ? shown.map(line => line.trim()).join('\n') : usage);
122
+ const example = Object.entries(examples).find(([command]) => words.join(' ').startsWith(command));
123
+ if (example) console.log(`\nExample --file INPUT.json for ${example[0]}:\n${JSON.stringify(example[1], null, 1)}\nSteps and rules: the "Save prospects and contacts" section of llms.txt.`);
124
+ }
125
+
95
126
  async function main() {
127
+ if (args.length > 1 && args.slice(1).some(value => value === '--help' || value === '-h') && !['tools', 'call', 'skills'].includes(args[0])) { commandHelp(); return; }
96
128
  if(args[0]==='prospects'&&['intake','conversation'].includes(args[1])){const {accountIntakeCli}=await import('./account-intake.mjs');await accountIntakeCli(args);return;}
97
129
  if(args[0]==='sender'){const {senderAccountsCli}=await import('./sender-accounts.mjs');await senderAccountsCli(args);return;}
98
130
  if(args[0]==='prospecting'){const {prospectingSettingsCli}=await import('./prospecting-settings.mjs');await prospectingSettingsCli(args);return;}
@@ -100,9 +132,11 @@ async function main() {
100
132
  if(args[0]==='prospects'&&args[1]==='contacts'){const {recipientsCli}=await import('./recipients.mjs');await recipientsCli(args);return;}
101
133
  if(args[0]==='usage'){const {usageCli}=await import('./usage.mjs');await usageCli(args);return;}
102
134
  if(args[0]==='audit'){const {auditCli}=await import('./audit.mjs');await auditCli(args);return;}
135
+ if(args[0]==='secret'||args[0]==='secrets'){const {secretsCli}=await import('./secrets.mjs');await secretsCli(args);return;}
103
136
  if(args[0]==='budgets'){const {budgetsCli}=await import('./budgets.mjs');await budgetsCli(args);return;}
104
137
  if(args[0]==='guardrails'||args[0]==='dnc'){const {guardrailsCli}=await import('./guardrails.mjs');await guardrailsCli(args);return;}
105
138
  if(args[0]==='prospects'&&args[1]==='suppression'){const {suppressionCli}=await import('./suppression.mjs');await suppressionCli(args);return;}
139
+ if(args[0]==='call'){const {callCli}=await import('./tools.mjs');await callCli(args);return;}
106
140
  if(args[0]==='tools'){const {toolsCli}=await import('./tools.mjs');await toolsCli(args);return;}
107
141
  if(['topics','posts'].includes(args[0])){const {contentCli}=await import('./content.mjs');await contentCli(args);return;}
108
142
  if(args[0]==='work'&&args[1]==='execution'){const {workExecutionCli}=await import('./work-execution.mjs');await workExecutionCli(args);return;}
@@ -111,14 +145,16 @@ async function main() {
111
145
  if(['prospects','campaigns'].includes(args[0])){const {discoveryCli}=await import('./discovery.mjs');await discoveryCli(args);return;}
112
146
  if(args[0]==='analytics'&&['cohorts','aging','filters'].includes(args[1])){const {cohortCli}=await import('./cohorts.mjs');await cohortCli(args);return;}
113
147
  if(args[0]==='analytics'&&['outbound','activity'].includes(args[1])){const {outboundCli}=await import('./outbound.mjs');await outboundCli(args);return;}
114
- if(['approach','analytics','pipeline','connections'].includes(args[0])){const {progressCli}=await import('./progress.mjs');await progressCli(args);return;}
148
+ if(['playbook','analytics','pipeline','connections'].includes(args[0])){const {progressCli}=await import('./progress.mjs');await progressCli(args);return;}
115
149
  if(args[0]==='campaign'){const {campaignCli}=await import('./campaigns.mjs');await campaignCli(args);return;}
116
150
  if(args[0]==='audience'){const {audienceCli}=await import('./audiences.mjs');await audienceCli(args);return;}
117
151
  if(args[0]==='file'){const {fileCli}=await import('./files.mjs');await fileCli(args);return;}
118
152
  if(args[0]==='ask'){const {askCli}=await import('./questions.mjs');await askCli(args);return;}
119
153
  if(args[0]==='questions'){const {questionsCli}=await import('./questions.mjs');await questionsCli(args);return;}
120
154
  if(args[0]==='queue'){const {queueCli}=await import('./queue.mjs');await queueCli(args);return;}
155
+ if(args[0]==='outcomes'){const {outcomesCli}=await import('./outcomes.mjs');await outcomesCli(args);return;}
121
156
  if(args[0]==='rooms'){const {roomsCli}=await import('./rooms.mjs');await roomsCli(args);return;}
157
+ if(args[0]==='kv'){const {kvCli}=await import('./kv.mjs');await kvCli(args);return;}
122
158
  if(args[0]==='offer'){const {offerCli}=await import('./offers.mjs');await offerCli(args);return;}
123
159
  if (args[0] === 'agent') { const { agentCli } = await import('./agents.mjs'); await agentCli(args); return; }
124
160
  if (isAssignmentCommand(args)) { await assignmentCli(args); return; }
@@ -132,6 +168,7 @@ async function main() {
132
168
  if (args[0] === 'whoami' && profileIndex > 0 && args[profileIndex + 1]) {
133
169
  const { configuration } = await import('./workspaces.mjs');
134
170
  profileConfig = configuration(args[profileIndex + 1], 'account:read');
171
+ if (!profileConfig.token) throw new Error('whoami needs a human account token. Agents confirm their connection with: audienti workspace list --profile PATH');
135
172
  args.splice(profileIndex, 2);
136
173
  }
137
174
  if (args[0] !== 'whoami' || args.length > 2 || (args[1] && args[1] !== '--json')) throw new Error('Unknown command or option. Use audienti --help.');
@@ -0,0 +1,155 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {HostedResponseUnknown} from './workspaces.mjs';
3
+ // #257: find N prospects for a Campaign. Searches LinkedIn with the Audience filters, then asks the server
4
+ // to check each new person. The server fetches, runs the rules and records the result; this file only
5
+ // drives the loop and adds up the spend. Amounts are pico USD (1 credit = 10,000,000,000).
6
+ export const PICO_PER_CREDIT=10_000_000_000n;
7
+ // Waits before each retry of a check whose outcome is unknown, in milliseconds.
8
+ const RECOVERY_WAITS=[2000,5000,15000];
9
+ const OUTCOMES=['fit','held','not_fit','already_found'];
10
+ // Only a definite answer lets the run go on. A check that is still open, a lost response or a server
11
+ // error may still be paying, so the same request ID is retried; anything else unexpected stops the run.
12
+ const pending=error=>error instanceof HostedResponseUnknown||error?.code==='check_running'||error?.status>=500&&!['tool_unavailable','history_policy_unavailable'].includes(error?.code);
13
+ // A refusal about this one person (a bad URL, no LinkedIn profile) is final, and the run moves on.
14
+ const skippable=error=>[400,404,422].includes(error?.status);
15
+ const MAX_SEARCHES=12,MAX_PAGES=10;
16
+ const positive=list=>(list??[]).filter(item=>!item.negative);
17
+ const numeric=value=>typeof value==='string'&&/^\d{1,20}$/.test(value);
18
+ // A charge the server reported for one request. Charges are cumulative per request ID, so the largest one
19
+ // seen across attempts is the request's charge so far.
20
+ const reportedCharge=value=>numeric(value)?BigInt(value):0n;
21
+
22
+ /** One profiles.search input per title, location and keyword combination the Audience and Campaign name. */
23
+ export function planSearches(audience,signals=[]){
24
+ const titles=positive(audience.person?.currentTitles).map(item=>item.name).filter(Boolean).slice(0,5);
25
+ // #281: the people search has no company region filter, so with no person location the company's region is
26
+ // searched as where the person is. Without it the search pays to check people anywhere in the world.
27
+ const places=positive(audience.person?.locations).length?positive(audience.person?.locations):positive(audience.company?.locations);
28
+ const locations=places.slice(0,3).map(item=>numeric(item.providerId)?{geoId:item.providerId}:{location:item.name});
29
+ // #322: the search takes 20 industry ids at a time, so a longer list is split across searches, never cut.
30
+ const industryIds=[...new Set(positive(audience.company?.industries).map(item=>item.providerId).filter(numeric))];
31
+ const industryChunks=[];for(let i=0;i<industryIds.length;i+=20)industryChunks.push(industryIds.slice(i,i+20));
32
+ // A topic signal is a keyword people use; research and profile signals are judged after the fetch, not searched.
33
+ const queries=[...new Set([audience.discoveryKeyword,...signals.filter(signal=>signal.kind==='topic').map(signal=>signal.topic)].map(value=>value?.trim()).filter(Boolean))].slice(0,3);
34
+ // At most MAX_SEARCHES title, place and keyword combinations. Each one is searched across every industry chunk, so
35
+ // a long industry list never pushes a title out of the plan. The credit cap still bounds what the searches cost.
36
+ const combos=[];
37
+ for(const title of titles.length?titles:[undefined])for(const place of locations.length?locations:[{}])for(const query of queries.length?queries:[undefined])combos.push({kind:'person',...(title?{title}:{}),...place,...(query?{query}:{})});
38
+ const searches=[];
39
+ for(const combo of combos.slice(0,MAX_SEARCHES))for(const ids of industryChunks.length?industryChunks:[undefined]){
40
+ const input={...combo,...(ids?{industryIds:ids}:{})};
41
+ if(input.title||input.query||input.geoId||input.location||input.industryIds)searches.push(input);
42
+ }
43
+ return searches;
44
+ }
45
+
46
+ export function parseCredits(value){
47
+ if(typeof value!=='string'||!/^\d{1,7}(\.\d{1,4})?$/.test(value))throw new Error('Set --max-credits to a number of credits, like 50 or 12.5.');
48
+ const [whole,fraction='']=value.split('.');
49
+ return BigInt(whole)*PICO_PER_CREDIT+BigInt(fraction.padEnd(4,'0'))*(PICO_PER_CREDIT/10_000n);
50
+ }
51
+ export const credits=pico=>{const whole=pico/PICO_PER_CREDIT,rest=(pico%PICO_PER_CREDIT)*10_000n/PICO_PER_CREDIT;return rest?`${whole}.${rest.toString().padStart(4,'0').replace(/0+$/,'')}`:`${whole}`;};
52
+ const charged=data=>data?.billing?.status==='charged'&&/^\d+$/.test(data.billing.amount??'')?BigInt(data.billing.amount):0n;
53
+ // A search is final once its outcome is known and its charge has settled.
54
+ const searchSettled=data=>['succeeded','failed','rejected'].includes(data?.status)&&(data.billing?.status==='not_charged'||charged(data)>0n||data.billing?.status==='charged'&&data.billing.amount==='0');
55
+ const personUrl=url=>typeof url==='string'&&/^https:\/\/([a-z]{2,3}\.)?(www\.)?linkedin\.com\/in\//i.test(url);
56
+
57
+ async function settled(call,workspaceId,data,wait){
58
+ for(let tries=0;data?.operationId&&!searchSettled(data)&&data.status!=='uncertain'&&tries<30;tries++){await wait(2000);data=(await call('tools_runs_get',{workspaceId,operationId:data.operationId})).data;}
59
+ return data;
60
+ }
61
+
62
+ /**
63
+ * Searches and checks until `count` new fits are found, the searches run out, or the next paid call could pass
64
+ * `maxCredits`. A search runs only when its published price fits what is left. Each check sends what is left as
65
+ * `maxCharge`, and the server refuses a check whose worst case (both profile fetches paid) could pass it.
66
+ */
67
+ export async function findCampaignProspects(call,{workspaceId,campaignId,count,maxCredits},{wait=ms=>new Promise(resolve=>setTimeout(resolve,ms)),progress=()=>{}}={}){
68
+ const {data:campaign}=await call('campaign_get',{workspaceId,campaignId});
69
+ const fields=campaign.revision.fields;
70
+ if(!['Draft','Preparing','Active'].includes(campaign.campaign.state))throw new Error('This campaign is not finding prospects. Pick a Draft, Preparing or Active campaign.');
71
+ const {data:audience}=await call('audience_get',{workspaceId,audienceId:fields.audienceId,revision:fields.audienceRevision});
72
+ // #322: the search cannot apply an older audience's industry group, so it would pay to check people in any
73
+ // industry. Refuse before any paid call; named industries are searched.
74
+ const company=audience.revision.fields.company;
75
+ if(company?.industryGroups?.length&&!positive(company.industries).length)throw new Error("This campaign's audience uses an older industry group, which the LinkedIn search cannot apply. Name the industries in the audience instead (audienti audience options --kind industries --q TEXT), then try again.");
76
+ const searches=planSearches(audience.revision.fields,fields.signals);
77
+ // #323: rules that will reject people (a named title below the seniority floor) are shown before the first paid call.
78
+ const warnings=audience.revision.warnings??[];
79
+ for(const warning of warnings)progress({warning});
80
+ if(!searches.length)throw new Error("This campaign's audience has no title, location, industry or keyword to search by. Add one, then try again.");
81
+ const {data:tool}=await call('tools_get',{workspaceId,tool:'profiles.search'});
82
+ if(!/^\d{1,20}$/.test(tool?.price?.picoUsd??''))throw new Error('The LinkedIn search has no fixed price right now, so the credit cap cannot be kept. Try again later.');
83
+ const searchPrice=BigInt(tool.price.picoUsd);
84
+ const report={campaignId,state:campaign.campaign.state,target:count,maxCredits:credits(maxCredits),spentCredits:'0',spent:'0',searches:0,checked:0,fit:0,held:0,notFit:0,alreadyFound:0,failed:0,stoppedBecause:'searches_exhausted',fits:[],heldPeople:[],...(warnings.length?{audienceWarnings:warnings}:{})};
85
+ let spent=0n;const seen=new Set();
86
+ const finish=reason=>{report.stoppedBecause=reason;report.spent=spent.toString();report.spentCredits=credits(spent);return report;};
87
+ // The check may have paid more than it reported; the rest is recovered with the same request ID.
88
+ const unknown=(url,requestId,error)=>{report.unknownCheck={url,requestId,code:error?.code??'response_unknown'};return finish('outcome_unknown');};
89
+ for(const input of searches){
90
+ for(let page=1;page&&page<=MAX_PAGES;){
91
+ if(report.fit>=count)return finish('count_reached');
92
+ if(spent+searchPrice>maxCredits)return finish('credit_cap');
93
+ // Search results are not saved as People; only a person the check fetches joins People.
94
+ // Nothing else pays until this search has settled: a search still running, uncertain or with a charge not
95
+ // yet final is retried with the same request ID, and if it never settles the run stops and names it.
96
+ const search={workspaceId,requestId:randomUUID(),input:{...input,...(page>1?{page}:{}),saveProfiles:false}};
97
+ let data=null,failure=null,paid=0n;
98
+ for(let attempt=0;;attempt++){
99
+ try{data=await settled(call,workspaceId,(await call('tools_profiles_search',search)).data,wait);failure=null;}
100
+ catch(error){if(!pending(error))throw error;failure=error;}
101
+ if(!failure&&charged(data)>paid)paid=charged(data);
102
+ if(!failure&&searchSettled(data))break;
103
+ if(attempt>=RECOVERY_WAITS.length){
104
+ spent+=paid;report.searches++;
105
+ report.unknownSearch={requestId:search.requestId,operationId:data?.operationId??null,code:failure?.code??data?.error?.code??data?.status??'response_unknown'};
106
+ return finish('outcome_unknown');
107
+ }
108
+ await wait(RECOVERY_WAITS[attempt]);
109
+ }
110
+ spent+=paid;report.searches++;
111
+ if(data?.status!=='succeeded'){if(data?.error?.code==='insufficient_credits')return finish('no_credits');break;}
112
+ const items=(data.result?.items??[]).filter(item=>personUrl(item.url)&&!seen.has(item.url));
113
+ for(const item of items){
114
+ seen.add(item.url);
115
+ if(report.fit>=count)return finish('count_reached');
116
+ if(spent>=maxCredits)return finish('credit_cap');
117
+ // Nothing else pays until this check has a definite answer. The same request ID replays the stored
118
+ // outcome and charge; if no definite answer comes, the run stops and names the check to recover.
119
+ const args={workspaceId,campaignId,url:item.url,requestId:randomUUID(),maxCharge:(maxCredits-spent).toString()};
120
+ // Every attempt's reported charge is kept, and the largest is counted once on every way out.
121
+ let result,failure,paid=0n;
122
+ for(let attempt=0;;attempt++){
123
+ try{result=(await call('campaign_prospect_check',args)).data;failure=null;}
124
+ catch(error){result=null;failure=error;}
125
+ const reported=reportedCharge(failure?failure.charged:result?.charged);
126
+ if(reported>paid)paid=reported;
127
+ if(!failure&&!OUTCOMES.includes(result?.outcome)){failure={code:'invalid_response'};break;}
128
+ if(!failure||!pending(failure)||attempt>=RECOVERY_WAITS.length)break;
129
+ await wait(RECOVERY_WAITS[attempt]);
130
+ }
131
+ spent+=paid;
132
+ if(failure){
133
+ const error=failure;
134
+ if(pending(error)||error.code==='invalid_response')return unknown(item.url,args.requestId,error);
135
+ if(error.code==='check_over_budget')return finish('credit_cap');
136
+ if(error.status===402)return finish('no_credits');
137
+ if(error.code==='campaign_not_active')return finish('campaign_not_active');
138
+ if(['tool_unavailable','history_policy_unavailable'].includes(error.code))return finish('tool_unavailable');
139
+ if(!skippable(error))return unknown(item.url,args.requestId,error);
140
+ report.failed++;progress({url:item.url,outcome:'failed',code:error.code,spent:credits(spent)});continue;
141
+ }
142
+ report.checked++;
143
+ const who={prospectId:result.prospectId,url:item.url,name:item.name??null,reason:result.assessment?.fit?.reason??null};
144
+ if(result.outcome==='fit'){report.fit++;report.fits.push({...who,admission:result.assessment?.admission});}
145
+ else if(result.outcome==='held'){report.held++;report.heldPeople.push({...who,reason:result.assessment?.reason??null});}
146
+ else if(result.outcome==='not_fit')report.notFit++;
147
+ else report.alreadyFound++;
148
+ progress({url:item.url,outcome:result.outcome,spent:credits(spent)});
149
+ }
150
+ // Follow the next page even when this one had only people already seen; MAX_PAGES still bounds it.
151
+ page=data.result?.nextPage>page?data.result.nextPage:0;
152
+ }
153
+ }
154
+ return finish(report.fit>=count?'count_reached':'searches_exhausted');
155
+ }
@@ -7,7 +7,7 @@ const signal=z.discriminatedUnion('kind',[
7
7
  z.object({...signalBase,kind:z.literal('topic'),topic:z.string()}).strict(),
8
8
  z.object({...signalBase,kind:z.literal('profile'),url:z.string(),track:z.enum(['audience','activity']).optional(),interactions:z.array(z.enum(['comments','reactions'])).optional()}).strict(),
9
9
  ]);
10
- const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),approach:z.string().optional(),approachPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),targets:z.discriminatedUnion('kind',[
10
+ const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),playbookPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),targets:z.discriminatedUnion('kind',[
11
11
  z.object({kind:z.literal('none'),values:z.array(z.never())}).strict(),
12
12
  z.object({kind:z.literal('companies'),values:z.array(z.object({type:z.enum(['domain','linkedin-company']),value:z.string()}).strict())}).strict(),
13
13
  z.object({kind:z.literal('people'),values:z.array(z.object({type:z.literal('linkedin-person'),value:z.string()}).strict())}).strict(),
@@ -15,12 +15,15 @@ const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number
15
15
  const path=a=>`/workspaces/${a.workspaceId}/campaigns`,detail=a=>path(a)+`/${a.campaignId}`;
16
16
  const qs=a=>new URLSearchParams(Object.fromEntries(Object.entries(a).filter(([k,v])=>!['workspaceId','campaignId'].includes(k)&&v!==undefined).map(([k,v])=>[k,String(v)])));
17
17
  export const campaignOperations={
18
- campaign_summary:{schema:z.object({...workspace}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/campaign-summary`,scoped:true,description:'Counts of Campaigns per state, and per Campaign its Audience, offer, approach, goal stage, methods, people, engaged and at-goal counts since start.'},
18
+ campaign_summary:{schema:z.object({...workspace}).strict(),method:'GET',path:a=>`/workspaces/${a.workspaceId}/campaign-summary`,scoped:true,description:'Counts of Campaigns per state, and per Campaign its Audience, offer, playbook, goal stage, methods, people, engaged and at-goal counts since start.'},
19
19
  campaign_list:{schema:z.object({...workspace,state:state.optional(),archived:z.boolean().optional(),q:z.string().optional(),limit:z.number().int().optional(),after:uuid.optional()}).strict(),method:'GET',path:a=>path(a)+'?'+qs(a),scoped:true},
20
20
  campaign_get:{schema:z.object({...locator,revision:z.number().int().optional()}).strict(),method:'GET',path:a=>detail(a)+'?'+qs(a),scoped:true},
21
21
  campaign_events:{schema:z.object({...locator,limit:z.number().int().optional(),after:uuid.optional()}).strict(),method:'GET',path:a=>detail(a)+'/events?'+qs(a),scoped:true},
22
22
  campaign_create:{schema:z.object({...workspace,requestId:uuid,fields}).strict(),method:'POST',path,body:a=>({requestId:a.requestId,fields:a.fields}),scoped:true},
23
23
  campaign_update:{schema:z.object({...locator,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/revisions',body:a=>({expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
24
- campaign_transition:{schema:z.object({...locator,expectedState:state,operation:z.enum(['prepare','activate','pause','resume','close','archive','restore']),reason:z.string().optional()}).strict(),method:'POST',path:a=>detail(a)+'/transitions',body:a=>({expectedState:a.expectedState,operation:a.operation,...(a.reason===undefined?{}:{reason:a.reason})}),scoped:true,description:'Move a Campaign through its lifecycle. prepare: Draft to Preparing (signal tracking and prospect finding run, no engagement). activate: Draft or Preparing to Active (finding and engagement run). pause: stop automation. resume: back to the paused state. close: Active to Closing (finding stops, engagement continues). archive: stop everything and hide it. restore: Archived to Paused, or Draft for a draft. Agents do not start or activate campaigns; the owner does.'},
24
+ campaign_transition:{schema:z.object({...locator,expectedState:state,operation:z.enum(['prepare','activate','pause','resume','close','archive','restore']),reason:z.string().optional()}).strict(),method:'POST',path:a=>detail(a)+'/transitions',body:a=>({expectedState:a.expectedState,operation:a.operation,...(a.reason===undefined?{}:{reason:a.reason})}),scoped:true,description:'Move a Campaign through its lifecycle. prepare: Draft to Preparing (signal tracking and prospect finding run, no engagement). activate: Draft or Preparing to Active (finding and engagement run). pause: stop automation. resume: back to the paused state. close: Active to Closing (finding stops, engagement continues). archive: stop everything and hide it. restore: Archived to Paused, or Draft for a draft. Agents do not start or activate campaigns; the user does. A campaign that cannot run is refused with campaign_not_ready and its missing items in blockers.'},
25
+ campaign_pace_get:{schema:z.object({...locator}).strict(),method:'GET',path:a=>detail(a)+'/pace',scoped:true,description:'Read a Campaign pace: new people a day. Derived is 25 a day per confirmed LinkedIn account, each capped by its reported connection-request limit; an email-only Campaign uses its own number, 50 by default, within mailbox limits. The reply names the LinkedIn accounts, the weekly figure and the expected daily credits for hosted lookups.'},
26
+ campaign_pace_set:{schema:z.object({...locator,perDay:z.union([z.number().int().min(1).max(1000),z.literal('derived')]).optional(),maxCredits:z.number().int().min(1).max(100000).nullable().optional()}).strict().refine(a=>a.perDay!==undefined||a.maxCredits!==undefined,{message:'Send perDay, maxCredits or both.'}),method:'POST',path:a=>detail(a)+'/pace',body:a=>({...(a.perDay===undefined?{}:{perDay:a.perDay}),...(a.maxCredits===undefined?{}:{maxCredits:a.maxCredits})}),scoped:true,description:'Save a Campaign pace. Pass the number the user gave, or "derived" to follow the confirmed sending accounts. An owner number stays until changed and is still capped by LinkedIn account and mailbox limits. maxCredits saves the credits a day the user approved for finding (null removes it); each day\'s find task carries it.'},
27
+ campaign_prospect_check:{schema:z.object({...locator,url:z.string().max(1024),requestId:uuid,maxCharge:z.string().regex(/^\d{1,20}$/).optional()}).strict(),method:'POST',path:a=>detail(a)+'/prospect-checks',body:a=>({url:a.url,requestId:a.requestId,...(a.maxCharge!==undefined?{maxCharge:a.maxCharge}:{})}),scoped:true,recoverable:true,timeoutMs:90000,description:'Check one LinkedIn person URL for a Campaign and record the result. Fetches the person and current company (saved profiles are reused free; otherwise each fetch spends hosted credits), runs the pinned Audience rules and records a qualification with the profiles as evidence. outcome fit: admitted, or shortlisted while the Campaign is a Draft. held: an agent must judge a smart rule, a signal or an undecided rule. not_fit: a rule failed. already_found: already in or shortlisted for this Campaign; nothing written. charged and balance are pico USD (1 credit = 10,000,000,000). Generate requestId once and reuse it after an interruption so a retry never pays twice.'},
25
28
  campaign_successor:{schema:z.object({...locator,requestId:uuid,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/successors',body:a=>({requestId:a.requestId,expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
26
29
  };