@audienti/cli 0.2.0-exora.4 → 0.2.0-exora.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/account-reply-operations.mjs +24 -1
- package/cli/agents.mjs +42 -10
- package/cli/audience-operations.mjs +1 -1
- package/cli/audienti.mjs +33 -18
- package/cli/campaign-find.mjs +155 -0
- package/cli/campaign-operations.mjs +8 -4
- package/cli/campaigns.mjs +34 -3
- package/cli/cohort-operations.mjs +1 -1
- package/cli/discovery-operations.mjs +2 -1
- package/cli/discovery.mjs +2 -1
- package/cli/kv-operations.mjs +17 -0
- package/cli/kv.mjs +58 -0
- package/cli/mcp.mjs +2 -2
- package/cli/outcome-operations.mjs +19 -0
- package/cli/outcomes.mjs +16 -0
- package/cli/owner-account-client.mjs +1 -1
- package/cli/{approach-operations.mjs → playbook-operations.mjs} +15 -15
- package/cli/progress.mjs +7 -7
- package/cli/question-operations.mjs +4 -4
- package/cli/questions.mjs +4 -4
- package/cli/queue-operations.mjs +7 -6
- package/cli/queue.mjs +4 -3
- package/cli/recipient-operations.mjs +1 -1
- package/cli/recipients.mjs +6 -2
- package/cli/rooms-operations.mjs +4 -4
- package/cli/rooms.mjs +4 -4
- package/cli/secret-operations.mjs +7 -0
- package/cli/secrets.mjs +30 -0
- package/cli/sender-account-operations.mjs +6 -2
- package/cli/sender-accounts.mjs +13 -8
- package/cli/skills.mjs +5 -5
- package/cli/tools-operations.mjs +18 -10
- package/cli/tools.mjs +48 -20
- package/cli/work-execution-operations.mjs +6 -0
- package/cli/work-execution.mjs +3 -3
- package/cli/work-operations.mjs +4 -3
- package/cli/work.mjs +2 -2
- package/cli/workspaces.mjs +24 -14
- package/package.json +2 -2
- package/skills/agent-rooms/SKILL.md +13 -11
- package/skills/agent-rooms/references/commands.md +2 -2
- package/skills/audienti/SKILL.md +80 -0
- package/skills/getting-started/SKILL.md +186 -158
- package/skills/getting-started/references/audience.md +72 -7
- package/skills/getting-started/references/campaign-draft.md +53 -11
- package/skills/getting-started/references/capabilities.md +219 -0
- package/skills/getting-started/references/mailbox-check.md +64 -0
- package/skills/getting-started/references/outcome-checks.md +89 -0
- package/skills/getting-started/references/replies.md +152 -0
- package/skills/getting-started/references/reply-checks.md +54 -0
- package/skills/playbook-design/SKILL.md +132 -0
- package/skills/{approach-design → playbook-design}/references/fields.md +28 -22
- package/source-manifest.json +54 -43
- package/skills/approach-design/SKILL.md +0 -104
- 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://
|
|
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://
|
|
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.")
|
|
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:'
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
-
|
|
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
|
@@ -4,8 +4,9 @@ import { isAssignmentCommand, assignmentCli } from './assignment.mjs';
|
|
|
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
6
|
Skills: audienti skills list [--compact]|show SKILL|read SKILL RESOURCE|helper NAME --file INPUT.json|- [--json]
|
|
7
|
-
Tools: audienti tools
|
|
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,68 @@ 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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; done or skip again with a new --note corrects the note of a finished task)
|
|
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
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 [--file INPUT.json] --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] [--request-id UUID] --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; the secret records who read it last and when)
|
|
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
|
-
|
|
73
|
-
|
|
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
|
+
Target companies: campaign targets CAMPAIGN_ID [--limit N --after CURSOR] (reads the companies people must work at to join)
|
|
82
|
+
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
|
|
84
|
+
Analytics: analytics show --from ISO --to ISO [--campaign ID] [--playbook-version ID] [--format json|csv]
|
|
74
85
|
analytics outbound [--workspace ID] [--days N | --from ISO --to ISO] [--by sender|action|day] [--format json|csv]
|
|
75
86
|
analytics activity [--workspace ID] [--days N | --from ISO --to ISO] [--format json|csv]
|
|
76
87
|
analytics cohorts [--workspace ID] [--stage goal|KEY] [--cohorts N] [--horizon N] [filters] [--format json|csv]
|
|
77
88
|
analytics aging [--workspace ID] [--threshold-days N] [filters] [--format json|csv]
|
|
78
89
|
analytics filters [--workspace ID] (lists campaign, owner, offer, audience and tag values)
|
|
79
90
|
(filters: --campaign ID --owner ID --offer ID --audience ID --tag TAG; also accepted by analytics show)
|
|
80
|
-
Pipeline: pipeline show [--campaign ID] [--
|
|
91
|
+
Pipeline: pipeline show [--campaign ID] [--playbook-version ID]
|
|
81
92
|
Connections: connections list|register --file REQUEST.json|report --file REQUEST.json
|
|
82
93
|
Discovery writes use --file INPUT.json, --workspace ID and stable --request-id UUID
|
|
83
94
|
Workspace commands: workspace init|list|create|show|search|invite|invitations|use
|
|
@@ -94,10 +105,10 @@ Start local MCP stdio server: mcp
|
|
|
94
105
|
|
|
95
106
|
// Example inputs for the commands that save prospects, so `--help` shows a working shape.
|
|
96
107
|
const examples = {
|
|
97
|
-
'prospects observation create': { requestId: 'UUID', kind: 'person', source: { provider: '
|
|
108
|
+
'prospects observation create': { requestId: 'UUID', kind: 'person', source: { provider: 'linkedin', url: 'https://www.linkedin.com/in/jane-doe' }, fields: { name: 'Jane Doe', title: 'VP Sales', imageUrl: 'https://media.licdn.com/dms/image/PHOTO_ID' }, provenance: { url: 'https://example.com/team/jane' }, observedAt: '2026-01-01T00:00:00Z', skill: { id: 'signal-prospect-research', version: '1' } },
|
|
98
109
|
'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' }] },
|
|
99
110
|
'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' } },
|
|
100
|
-
'prospects contacts record': { kind: 'email|linkedin|mobile', value: 'jane@example.com, or +15551234567 for mobile', reason: 'Published on https://example.com/contact', requestId: 'UUID', provenance: { observationId: 'OBSERVATION_ID' } },
|
|
111
|
+
'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' } },
|
|
101
112
|
'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' } },
|
|
102
113
|
};
|
|
103
114
|
|
|
@@ -115,7 +126,7 @@ function commandHelp() {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
async function main() {
|
|
118
|
-
if (args.length > 1 && args.slice(1).some(value => value === '--help' || value === '-h') && !['tools', 'skills'].includes(args[0])) { commandHelp(); return; }
|
|
129
|
+
if (args.length > 1 && args.slice(1).some(value => value === '--help' || value === '-h') && !['tools', 'call', 'skills'].includes(args[0])) { commandHelp(); return; }
|
|
119
130
|
if(args[0]==='prospects'&&['intake','conversation'].includes(args[1])){const {accountIntakeCli}=await import('./account-intake.mjs');await accountIntakeCli(args);return;}
|
|
120
131
|
if(args[0]==='sender'){const {senderAccountsCli}=await import('./sender-accounts.mjs');await senderAccountsCli(args);return;}
|
|
121
132
|
if(args[0]==='prospecting'){const {prospectingSettingsCli}=await import('./prospecting-settings.mjs');await prospectingSettingsCli(args);return;}
|
|
@@ -123,9 +134,11 @@ async function main() {
|
|
|
123
134
|
if(args[0]==='prospects'&&args[1]==='contacts'){const {recipientsCli}=await import('./recipients.mjs');await recipientsCli(args);return;}
|
|
124
135
|
if(args[0]==='usage'){const {usageCli}=await import('./usage.mjs');await usageCli(args);return;}
|
|
125
136
|
if(args[0]==='audit'){const {auditCli}=await import('./audit.mjs');await auditCli(args);return;}
|
|
137
|
+
if(args[0]==='secret'||args[0]==='secrets'){const {secretsCli}=await import('./secrets.mjs');await secretsCli(args);return;}
|
|
126
138
|
if(args[0]==='budgets'){const {budgetsCli}=await import('./budgets.mjs');await budgetsCli(args);return;}
|
|
127
139
|
if(args[0]==='guardrails'||args[0]==='dnc'){const {guardrailsCli}=await import('./guardrails.mjs');await guardrailsCli(args);return;}
|
|
128
140
|
if(args[0]==='prospects'&&args[1]==='suppression'){const {suppressionCli}=await import('./suppression.mjs');await suppressionCli(args);return;}
|
|
141
|
+
if(args[0]==='call'){const {callCli}=await import('./tools.mjs');await callCli(args);return;}
|
|
129
142
|
if(args[0]==='tools'){const {toolsCli}=await import('./tools.mjs');await toolsCli(args);return;}
|
|
130
143
|
if(['topics','posts'].includes(args[0])){const {contentCli}=await import('./content.mjs');await contentCli(args);return;}
|
|
131
144
|
if(args[0]==='work'&&args[1]==='execution'){const {workExecutionCli}=await import('./work-execution.mjs');await workExecutionCli(args);return;}
|
|
@@ -134,14 +147,16 @@ async function main() {
|
|
|
134
147
|
if(['prospects','campaigns'].includes(args[0])){const {discoveryCli}=await import('./discovery.mjs');await discoveryCli(args);return;}
|
|
135
148
|
if(args[0]==='analytics'&&['cohorts','aging','filters'].includes(args[1])){const {cohortCli}=await import('./cohorts.mjs');await cohortCli(args);return;}
|
|
136
149
|
if(args[0]==='analytics'&&['outbound','activity'].includes(args[1])){const {outboundCli}=await import('./outbound.mjs');await outboundCli(args);return;}
|
|
137
|
-
if(['
|
|
150
|
+
if(['playbook','analytics','pipeline','connections'].includes(args[0])){const {progressCli}=await import('./progress.mjs');await progressCli(args);return;}
|
|
138
151
|
if(args[0]==='campaign'){const {campaignCli}=await import('./campaigns.mjs');await campaignCli(args);return;}
|
|
139
152
|
if(args[0]==='audience'){const {audienceCli}=await import('./audiences.mjs');await audienceCli(args);return;}
|
|
140
153
|
if(args[0]==='file'){const {fileCli}=await import('./files.mjs');await fileCli(args);return;}
|
|
141
154
|
if(args[0]==='ask'){const {askCli}=await import('./questions.mjs');await askCli(args);return;}
|
|
142
155
|
if(args[0]==='questions'){const {questionsCli}=await import('./questions.mjs');await questionsCli(args);return;}
|
|
143
156
|
if(args[0]==='queue'){const {queueCli}=await import('./queue.mjs');await queueCli(args);return;}
|
|
157
|
+
if(args[0]==='outcomes'){const {outcomesCli}=await import('./outcomes.mjs');await outcomesCli(args);return;}
|
|
144
158
|
if(args[0]==='rooms'){const {roomsCli}=await import('./rooms.mjs');await roomsCli(args);return;}
|
|
159
|
+
if(args[0]==='kv'){const {kvCli}=await import('./kv.mjs');await kvCli(args);return;}
|
|
145
160
|
if(args[0]==='offer'){const {offerCli}=await import('./offers.mjs');await offerCli(args);return;}
|
|
146
161
|
if (args[0] === 'agent') { const { agentCli } = await import('./agents.mjs'); await agentCli(args); return; }
|
|
147
162
|
if (isAssignmentCommand(args)) { await assignmentCli(args); return; }
|
|
@@ -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,20 +7,24 @@ 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(),
|
|
10
|
+
const fields=z.object({name:z.string(),audienceId:uuid,audienceRevision:z.number().int(),offerId:uuid,offerRevision:z.number().int(),premise:z.string().optional(),playbookPin:z.object({id:uuid,revision:z.number().int().positive()}).strict().optional(),notes:z.string().optional(),ownerUserId:uuid.nullable().optional(),tags:z.array(z.string()).optional(),signalMatch:z.enum(['any','all']).optional(),signals:z.array(signal).optional(),method:z.enum(['signals','listen','track-audience','track-engagement','custom']).optional(),findingInstructions:z.string().optional(),targets:z.discriminatedUnion('kind',[
|
|
11
11
|
z.object({kind:z.literal('none'),values:z.array(z.never())}).strict(),
|
|
12
12
|
z.object({kind:z.literal('companies'),values:z.array(z.object({type:z.enum(['domain','linkedin-company']),value:z.string()}).strict())}).strict(),
|
|
13
|
-
z.object({kind:z.literal('people'),values:z.array(z.object({type:z.literal('linkedin-person'),value:z.string()}).strict())}).strict(),
|
|
14
13
|
]).optional(),channels:z.array(z.enum(['email','linkedin'])).optional(),approvalMode:z.literal('manual').optional(),contentOpsEnabled:z.boolean().optional(),schedule:z.object({startAt:z.string().nullable().optional(),endAt:z.string().nullable().optional(),maxCompanies:z.number().int().nullable().optional()}).strict().optional()}).strict();
|
|
15
14
|
const path=a=>`/workspaces/${a.workspaceId}/campaigns`,detail=a=>path(a)+`/${a.campaignId}`;
|
|
16
15
|
const qs=a=>new URLSearchParams(Object.fromEntries(Object.entries(a).filter(([k,v])=>!['workspaceId','campaignId'].includes(k)&&v!==undefined).map(([k,v])=>[k,String(v)])));
|
|
17
16
|
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,
|
|
17
|
+
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
18
|
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
19
|
campaign_get:{schema:z.object({...locator,revision:z.number().int().optional()}).strict(),method:'GET',path:a=>detail(a)+'?'+qs(a),scoped:true},
|
|
21
20
|
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
21
|
campaign_create:{schema:z.object({...workspace,requestId:uuid,fields}).strict(),method:'POST',path,body:a=>({requestId:a.requestId,fields:a.fields}),scoped:true},
|
|
23
22
|
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
|
|
23
|
+
campaign_transition:{schema:z.object({...locator,expectedState:state,operation:z.enum(['prepare','activate','pause','resume','close','archive','restore']),reason:z.string().optional()}).strict(),method:'POST',path:a=>detail(a)+'/transitions',body:a=>({expectedState:a.expectedState,operation:a.operation,...(a.reason===undefined?{}:{reason:a.reason})}),scoped:true,description:'Move a Campaign through its lifecycle. prepare: Draft to Preparing (signal tracking and prospect finding run, no engagement). activate: Draft or Preparing to Active (finding and engagement run). pause: stop automation. resume: back to the paused state. close: Active to Closing (finding stops, engagement continues). archive: stop everything and hide it. restore: Archived to Paused, or Draft for a draft. Agents do not start or activate campaigns; the user does. A campaign that cannot run is refused with campaign_not_ready and its missing items in blockers.'},
|
|
24
|
+
campaign_pace_get:{schema:z.object({...locator}).strict(),method:'GET',path:a=>detail(a)+'/pace',scoped:true,description:'Read a Campaign pace: new people a day. Derived is 25 a day per confirmed LinkedIn account, each capped by its reported connection-request limit; an email-only Campaign uses its own number, 50 by default, within mailbox limits. The reply names the LinkedIn accounts, the weekly figure and the expected daily credits for hosted lookups.'},
|
|
25
|
+
campaign_pace_set:{schema:z.object({...locator,perDay:z.union([z.number().int().min(1).max(1000),z.literal('derived')]).optional(),maxCredits:z.number().int().min(1).max(100000).nullable().optional()}).strict().refine(a=>a.perDay!==undefined||a.maxCredits!==undefined,{message:'Send perDay, maxCredits or both.'}),method:'POST',path:a=>detail(a)+'/pace',body:a=>({...(a.perDay===undefined?{}:{perDay:a.perDay}),...(a.maxCredits===undefined?{}:{maxCredits:a.maxCredits})}),scoped:true,description:'Save a Campaign pace. Pass the number the user gave, or "derived" to follow the confirmed sending accounts. An owner number stays until changed and is still capped by LinkedIn account and mailbox limits. maxCredits saves the credits a day the user approved for finding (null removes it); each day\'s find task carries it.'},
|
|
26
|
+
campaign_targets_get:{schema:z.object({...locator,limit:z.number().int().min(1).max(500).optional(),after:z.string().regex(/^\d{1,7}$/).optional()}).strict(),method:'GET',path:a=>detail(a)+'/targets?'+qs(a),scoped:true,description:'Read a Campaign\'s target companies in pages of up to 500: domains and LinkedIn company URLs. Only people whose current company is one of them can join.'},
|
|
27
|
+
campaign_targets_change:{schema:z.object({...locator,expectedRevision:z.number().int().positive(),add:z.array(z.string()).optional(),remove:z.array(z.string()).optional(),set:z.array(z.string()).optional()}).strict().refine(a=>(a.set===undefined)!==(a.add===undefined&&a.remove===undefined),{message:'Send set, or add and or remove.'}),method:'POST',path:a=>detail(a)+'/targets',body:a=>({expectedRevision:a.expectedRevision,...Object.fromEntries(['add','remove','set'].filter(k=>a[k]!==undefined).map(k=>[k,a[k]]))}),scoped:true,description:'Change a Campaign\'s target companies: add or remove domains (acme.com) and LinkedIn company URLs, or replace them all with set. Saves a new Campaign revision; an empty set removes the targets.'},
|
|
28
|
+
campaign_prospect_check:{schema:z.object({...locator,url:z.string().max(1024),requestId:uuid,maxCharge:z.string().regex(/^\d{1,20}$/).optional()}).strict(),method:'POST',path:a=>detail(a)+'/prospect-checks',body:a=>({url:a.url,requestId:a.requestId,...(a.maxCharge!==undefined?{maxCharge:a.maxCharge}:{})}),scoped:true,recoverable:true,timeoutMs:90000,description:'Check one LinkedIn person URL for a Campaign and record the result. Fetches the person and current company (saved profiles are reused free; otherwise each fetch spends hosted credits), runs the pinned Audience rules and records a qualification with the profiles as evidence. outcome fit: admitted, or shortlisted while the Campaign is a Draft. held: an agent must judge a smart rule, a signal or an undecided rule. not_fit: a rule failed. already_found: already in or shortlisted for this Campaign; nothing written. charged and balance are pico USD (1 credit = 10,000,000,000). Generate requestId once and reuse it after an interruption so a retry never pays twice.'},
|
|
25
29
|
campaign_successor:{schema:z.object({...locator,requestId:uuid,expectedRevision:z.number().int(),fields}).strict(),method:'POST',path:a=>detail(a)+'/successors',body:a=>({requestId:a.requestId,expectedRevision:a.expectedRevision,fields:a.fields}),scoped:true},
|
|
26
30
|
};
|