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

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 (76) hide show
  1. package/README.md +2 -2
  2. package/cli/account-replies.mjs +16 -2
  3. package/cli/account-reply-operations.mjs +28 -1
  4. package/cli/agents.mjs +56 -14
  5. package/cli/attachment-operations.mjs +9 -0
  6. package/cli/attachments.mjs +28 -0
  7. package/cli/audience-operations.mjs +1 -1
  8. package/cli/audienti.mjs +118 -27
  9. package/cli/audit-operations.mjs +2 -2
  10. package/cli/campaign-find.mjs +155 -0
  11. package/cli/campaign-operations.mjs +8 -4
  12. package/cli/campaigns.mjs +38 -3
  13. package/cli/cohort-operations.mjs +1 -1
  14. package/cli/content.mjs +2 -0
  15. package/cli/decision-operations.mjs +35 -0
  16. package/cli/decision.mjs +51 -0
  17. package/cli/discovery-operations.mjs +2 -1
  18. package/cli/discovery.mjs +3 -2
  19. package/cli/duplicate-operations.mjs +22 -0
  20. package/cli/duplicates.mjs +22 -0
  21. package/cli/hand-task-operations.mjs +11 -0
  22. package/cli/hand-tasks.mjs +18 -0
  23. package/cli/intent-operations.mjs +44 -0
  24. package/cli/intent.mjs +109 -0
  25. package/cli/json-file.mjs +1 -1
  26. package/cli/kv-operations.mjs +17 -0
  27. package/cli/kv.mjs +58 -0
  28. package/cli/mcp.mjs +4 -4
  29. package/cli/outcome-operations.mjs +28 -0
  30. package/cli/outcomes.mjs +16 -0
  31. package/cli/owner-account-client.mjs +5 -4
  32. package/cli/playbook-help.mjs +87 -0
  33. package/cli/playbook-operations.mjs +40 -0
  34. package/cli/progress.mjs +19 -8
  35. package/cli/prospecting-settings-operations.mjs +1 -1
  36. package/cli/question-operations.mjs +4 -4
  37. package/cli/questions.mjs +4 -4
  38. package/cli/queue-operations.mjs +7 -6
  39. package/cli/queue.mjs +4 -3
  40. package/cli/recipient-operations.mjs +1 -1
  41. package/cli/recipients.mjs +6 -2
  42. package/cli/rooms-operations.mjs +26 -5
  43. package/cli/rooms.mjs +32 -6
  44. package/cli/secret-operations.mjs +7 -0
  45. package/cli/secrets.mjs +30 -0
  46. package/cli/sender-account-operations.mjs +8 -3
  47. package/cli/sender-accounts.mjs +20 -10
  48. package/cli/skills-mcp.mjs +6 -2
  49. package/cli/skills.mjs +55 -12
  50. package/cli/tools-operations.mjs +19 -10
  51. package/cli/tools.mjs +48 -20
  52. package/cli/work-execution-operations.mjs +7 -0
  53. package/cli/work-execution.mjs +3 -3
  54. package/cli/work-operations.mjs +5 -4
  55. package/cli/work.mjs +2 -2
  56. package/cli/workspaces.mjs +104 -22
  57. package/package.json +2 -2
  58. package/skills/agent-rooms/SKILL.md +140 -0
  59. package/skills/agent-rooms/references/commands.md +78 -0
  60. package/skills/audienti/SKILL.md +111 -5
  61. package/skills/audienti/references/helpers.md +15 -2
  62. package/skills/audienti/references/profile-workflows.md +4 -2
  63. package/skills/audienti/scripts/contracts.mjs +1 -1
  64. package/skills/getting-started/SKILL.md +188 -154
  65. package/skills/getting-started/references/audience.md +72 -7
  66. package/skills/getting-started/references/campaign-draft.md +56 -13
  67. package/skills/getting-started/references/capabilities.md +238 -0
  68. package/skills/getting-started/references/mailbox-check.md +64 -0
  69. package/skills/getting-started/references/outcome-checks.md +125 -0
  70. package/skills/getting-started/references/replies.md +314 -0
  71. package/skills/getting-started/references/reply-checks.md +54 -0
  72. package/skills/playbook-design/SKILL.md +139 -0
  73. package/skills/playbook-design/references/fields.md +145 -0
  74. package/source-manifest.json +74 -48
  75. package/cli/approach-operations.mjs +0 -24
  76. 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
 
@@ -14,7 +14,7 @@ export async function accountRepliesCli(args) {
14
14
  if (positions[0] !== 'prospects' || positions[1] !== 'replies' || !['set', 'get', 'list'].includes(command) || positions.length !== 4) throw Error('Use prospects replies set|get|list PERSON --workspace ID.');
15
15
  const allowed = ['workspace', 'profile', ...(command === 'set' ? ['file', 'evidence'] : command === 'get' ? ['evidence', 'history-cursor', 'history-limit'] : ['cursor', 'limit', 'status'])];
16
16
  if (Object.keys(flags).some(k => !allowed.includes(k))) throw Error('Option does not apply to this reply command.');
17
- if (command !== 'list' && !flags.evidence) throw Error('--evidence EVIDENCE is required.');
17
+ if (command === 'set' && !flags.evidence) throw Error('--evidence EVIDENCE is required.');
18
18
  let input = {};
19
19
  if (command === 'set') {
20
20
  if (!flags.file) throw Error('--file INPUT.json is required.');
@@ -31,5 +31,19 @@ export async function accountRepliesCli(args) {
31
31
  if (!/^\d+$/.test(flags[flag])) throw Error('invalid_fields');
32
32
  input[key] = Number(flags[flag]);
33
33
  }
34
- console.log(JSON.stringify(await callWorkspace(`workspace_reply_${command}`, input, configuration(flags.profile))));
34
+ const config = configuration(flags.profile);
35
+ if (command === 'get' && !input.evidenceId) {
36
+ // No --evidence: read the person's latest reply. The list runs oldest first, so walk to its last page.
37
+ let last = null, cursor;
38
+ for (;;) {
39
+ const page = await callWorkspace('workspace_reply_list', { workspaceId: input.workspaceId, prospectId: input.prospectId, limit: 100, ...(cursor ? { cursor } : {}) }, config);
40
+ const data = page.data ?? page;
41
+ last = data.items?.at(-1)?.evidenceId ?? last;
42
+ if (!data.nextCursor) break;
43
+ cursor = data.nextCursor;
44
+ }
45
+ if (!last) throw Error('This person has no stored reply yet. Run prospects timeline PERSON to see their messages.');
46
+ input.evidenceId = last;
47
+ }
48
+ console.log(JSON.stringify(await callWorkspace(`workspace_reply_${command}`, input, config)));
35
49
  }
@@ -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', 'not_interested', 'out_of_office', 'stop', 'bounce', 'referral', 'wrong_person', 'unclear']);
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
+ export const replyResultInput = 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,11 +33,20 @@ 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. An out_of_office reply snoozes the person instead; an unclear reply holds the person for a decision; a wrong_person reply with no name given stops the sends and asks who the right person is. Someone who names the right person is a referral."),
38
+ returnsOn: z.string().datetime().optional().describe("Only with out_of_office: the date the person is back (UTC, ending in Z). The snooze lasts until then; with no date it follows the playbook's out-of-office rule, 7 days by default."),
39
+ invite: z.literal(true).optional().describe("The prospect sent a calendar invite you have not processed yet. Requires a classification; a meetings.confirm task follows."),
40
+ draft: draft.optional().describe("Your drafted answer. Saved as a reply plan revision the user reviews in Operator; requires status needs_handling. Never sent until approved."),
41
+ result: replyResultInput.optional().describe("Brief 12: the typed detail for a bounce, stop or referral. Its type must match the classification."),
42
+ meeting: z.object({ at: z.string().datetime(), source: z.enum(['sent_invite', 'received_invite']) }).strict().optional().describe("A booked meeting: when, and whether the user's calendar sent the invite or the prospect did. Records meeting_booked."),
43
+ intent: z.object({ quote: z.string().meta({ minLength: 1, maxLength: 300 }).describe('The words in the reply that support the classification or meeting. Copy them; case and spacing do not matter.'), reason: z.string().meta({ minLength: 1, maxLength: 200 }).describe('One plain sentence: why. A person reads it, so no IDs or error codes.'), confidence: z.enum(['high', 'medium', 'low']).optional().describe('How sure you are. Default high.') }).strict().optional().describe("The words behind your reading. With it, the label is checked like intent_record checks it (the quote is in the reply, the reason is plain) and the quote and reason are stored on the event. Requires a classification or a meeting."),
44
+ meetingState: z.enum(['requested', 'accepted', 'declined']).optional().describe("Where a meeting that is not booked yet stands after this reply: requested (a time was asked for), accepted (they agreed, no time yet) or declined. Records the matching meeting event once. Ignored once a meeting is booked.") }).strict().describe("Explicit inbound classification. needs_handling/not_reply require null outcome and responseEvidenceId. handled requires an outcome; response_recorded requires matching outbound evidence, closed_without_response an explicit closure reason, and suppressed active DNC or Reject."),
19
45
  method: 'POST', scoped: true, path: detail,
20
46
  body: ({ workspaceId, prospectId, evidenceId, ...rest }) => rest,
21
47
  },
22
48
  workspace_reply_get: {
49
+ description: 'Read one inbound message: its reply state and history, and the `labels` an agent or person recorded on it (label, title, quote, reason, confidence, who recorded it, when), newest first. Free CRM read. The CLI reads the latest reply of the person when `--evidence` is left out.',
23
50
  schema: z.object({ ...item, historyCursor: uuid.optional(), historyLimit: limit.optional() }).strict(),
24
51
  method: 'GET', scoped: true, path: a => query(detail(a), a, ['historyCursor', 'historyLimit']),
25
52
  },
package/cli/agents.mjs CHANGED
@@ -1,12 +1,23 @@
1
- import { readProfile, writeProfile, validateUrl, configuration, WorkspaceRequestError } from './workspaces.mjs';
1
+ import { readProfile, writeProfile, validateUrl, configuration, profileFrom, WorkspaceRequestError, errorGuidance } from './workspaces.mjs';
2
2
  import { z } from 'zod';
3
3
  import {readJsonFile} from './json-file.mjs';
4
4
 
5
5
  export const agentManagementOperations = ['agent_list','agent_revoke','agent_permission','agent_capabilities_set','agent_capabilities_get','agent_inventory_get','agent_capability_sharing_get','agent_capability_sharing_set','agent_account_authorizations_get','agent_account_authorization_set'];
6
+ // The seven capability groups and the 'approve' guardrail (#120). 'listen' is the retired name of 'research'; the server still accepts it.
7
+ const capabilityKeys=['find','enrich','expand','research','engage','sync','notify','approve','listen'];
6
8
  const inventoryLabel=z.string().trim().min(1).max(80);
9
+ // Capability rows (ADR 015). The server checks ids, providers and access against the catalog; the client checks the shape.
10
+ const capabilityRow=z.object({capability:z.string().min(1).max(64),provider:z.string().min(1).max(32).optional(),via:z.string().min(1).max(140),account:z.string().min(1).max(200).optional(),access:z.enum(['read','write','send']),cost:z.enum(['owner','credits']).optional(),status:z.enum(['connected','needs_sign_in','unavailable']),limits:z.object({daily:z.number().int().min(0).max(100000).optional(),hourly:z.number().int().min(0).max(100000).optional()}).strict().optional(),identity:z.object({namespace:z.string().min(1).max(64),accountId:z.string().min(1).max(256)}).strict().optional()}).strict();
11
+ // A LinkedIn seat's cadence, InMail and safe limits (brief 09); the server checks the bounds.
12
+ const seatLimit=z.number().int().min(0).max(100000);
13
+ const seatFacts={checkEverySeconds:z.number().int().min(3600).max(604800).optional(),inmail:z.object({available:z.boolean(),quotaPerMonth:seatLimit.optional(),creditsLeft:seatLimit.optional()}).strict().optional(),plan:z.enum(['free','premium_career','premium_business','premium_all_in_one','sales_navigator_core','sales_navigator_advanced','recruiter_lite']).optional(),limits:z.object({connectionRequestsPerDay:seatLimit.optional(),connectionRequestsPerWeek:seatLimit.optional(),messagesPerDay:seatLimit.optional(),inmailPerMonth:seatLimit.optional()}).strict().optional()};
14
+ const accountEntry=z.object({capability:z.string(),tools:z.array(z.string()).min(1).max(8)}).strict();
15
+ const accountReport=z.object({provider:z.enum(['email','linkedin']),accountRef:z.string().min(1).max(200),status:z.enum(['healthy','unavailable','disconnected']),accountIdentity:z.object({namespace:z.string(),accountId:z.string()}).strict().optional(),capabilities:z.array(accountEntry).max(16),...seatFacts}).strict();
16
+ const capabilityReport=z.object({runtime:z.object({name:z.string().trim().min(1).max(80),model:z.string().trim().min(1).max(80).optional()}).strict(),capabilities:z.array(capabilityRow).max(64),seats:z.array(z.object({provider:z.literal('linkedin'),account:z.string().min(1).max(200),...seatFacts}).strict()).max(50).optional()}).strict();
17
+ const eitherReport=z.union([capabilityReport,accountReport]);
7
18
  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
19
  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()},
20
+ 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
21
  agent_inventory_show:{description:'Read this agent\u2019s own reported tool inventory. Requires an active agent profile.',schema:z.object({}).strict()},
11
22
  agent_inventory_get:{schema:z.object({id:z.string().uuid()}).strict()},
12
23
  agent_account_authorizations_get:{schema:z.object({id:z.string().uuid()}).strict()},
@@ -14,12 +25,14 @@ export const agentOperations = {
14
25
  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
26
  agent_capability_sharing_get: {schema:z.object({id:z.string().uuid()}).strict()},
16
27
  agent_capability_sharing_set: {schema:z.object({id:z.string().uuid(),provider:z.enum(['email','linkedin']),accountRef:z.string().min(1).max(200),workspaceId:z.string().uuid(),shared:z.boolean()}).strict()},
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()},
28
+ // One object schema for MCP clients; callAgent then requires exactly one of the two report shapes.
29
+ agent_capabilities_report: {description:'Getting started step 2: report what this agent can do as capability rows ({ runtime, capabilities: [{ capability, provider?, via, account?, access, cost?, status, limits?, identity? }], seats?: [{ provider: "linkedin", account, checkEverySeconds?, plan?, inmail?, limits? }] }), ids from the capability catalog. A LinkedIn account sends only when its rows carry identity { namespace: "linkedin", accountId: the member id }; a mailbox without one is matched by its address. Replaces the previous report. The old per-account shape (provider, accountRef, status, capabilities) is still accepted. Never use via "audienti" and never include credentials. Requires an active agent profile.',schema:z.object({runtime:capabilityReport.shape.runtime.optional(),provider:accountReport.shape.provider.optional(),accountRef:accountReport.shape.accountRef.optional(),status:accountReport.shape.status.optional(),accountIdentity:accountReport.shape.accountIdentity,capabilities:z.array(z.union([capabilityRow,accountEntry])).max(64),seats:capabilityReport.shape.seats,...seatFacts}).strict(),shape:eitherReport},
30
+ agent_capabilities_list: {description:'Read what this agent can do now: its own capability rows plus Audienti hosted rows for the workspace (when the workspace has credits and the user approved the group). Uses the profile workspace unless workspaceId is given. Requires an active agent profile.',schema:z.object({workspaceId:z.string().uuid().optional()}).strict()},
18
31
  agent_capabilities_show: {schema:z.object({}).strict()},
19
32
  agent_capabilities_get: {schema:z.object({id:z.string().uuid()}).strict()},
20
33
  agent_permission: {schema:z.object({id:z.string().uuid(),scope:z.enum(['workspace:read','workspace:access'])}).strict()},
21
- agent_capabilities_set: {schema:z.object({id:z.string().uuid(),capabilities:z.array(z.enum(['find','enrich','listen','approve'])).min(1).max(4)}).strict()},
22
- agent_connect: { schema: z.object({ name: z.string().trim().min(1).max(80), capabilities: z.array(z.enum(['find','enrich','listen','approve'])).max(4).optional() }).strict() },
34
+ agent_capabilities_set: {schema:z.object({id:z.string().uuid(),capabilities:z.array(z.enum(capabilityKeys)).min(1).max(9)}).strict()},
35
+ agent_connect: { schema: z.object({ name: z.string().trim().min(1).max(80), capabilities: z.array(z.enum(capabilityKeys)).max(9).optional(), write: z.boolean().optional() }).strict() },
23
36
  agent_poll: { schema: z.object({}).strict() },
24
37
  agent_cancel: { schema: z.object({}).strict() },
25
38
  agent_list: { schema: z.object({}).strict() },
@@ -33,14 +46,17 @@ async function request(config, path, body, authenticated = false, method = body
33
46
  let result; try { result = await response.json(); } catch { throw new Error('The API returned an invalid response.'); }
34
47
  if (!response.ok) {
35
48
  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}).`);
49
+ // A refused report says why (ADR 015), with the same safety checks as the workspace client.
50
+ const message = result?.error?.message;
51
+ 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}` : '';
52
+ throw new WorkspaceRequestError(response.status, code, `Agent request failed: ${code} (HTTP ${response.status}).${detail}`, undefined, undefined, undefined, errorGuidance(result?.error, config.token));
37
53
  }
38
54
  if (!result || !Object.hasOwn(result, 'data')) throw new Error('The API returned an invalid response.');
39
55
  return result.data;
40
56
  }
41
57
  export async function callAgent(name, args, config, profilePath) {
42
58
  const input = agentOperations[name]?.schema.safeParse(args);
43
- if (!input?.success) throw new Error('Invalid agent command or arguments.');
59
+ if (!input?.success || (agentOperations[name].shape && !agentOperations[name].shape.safeParse(args).success)) throw new Error('Invalid agent command or arguments.');
44
60
  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
61
  if(name==='agent_account_authorizations_get') return {data:await request(config,`/agents/${input.data.id}/account-authorizations`,undefined,true)};
46
62
  if(name==='agent_account_authorization_set') {const {id,...body}=input.data;return {data:await request(config,`/agents/${id}/account-authorizations`,body,true)};}
@@ -56,6 +72,11 @@ export async function callAgent(name, args, config, profilePath) {
56
72
  return {data:await request(config,'/agent-inventory',name==='agent_inventory_report' ? input.data : undefined,true,name==='agent_inventory_report' ? 'PUT' : 'GET')};
57
73
  }
58
74
  if (name === 'agent_capabilities_get') return {data:await request(config,`/agents/${input.data.id}/capabilities`,undefined,true)};
75
+ if (name === 'agent_capabilities_list') {
76
+ if (!config.profile?.agent || !/^agt_/.test(config.token ?? '')) throw new Error('Use an active agent profile for capability check-in and self readback.');
77
+ const workspaceId = input.data.workspaceId ?? config.workspaceId;
78
+ return {data:await request(config,`/agent-capabilities${workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''}`,undefined,true)};
79
+ }
59
80
  if (name === 'agent_capabilities_show' || name === 'agent_capabilities_report') {
60
81
  if (!config.profile?.agent || !/^agt_/.test(config.token ?? '')) throw new Error('Use an active agent profile for capability check-in and self readback.');
61
82
  return {data:await request(config,'/agent-capabilities',name==='agent_capabilities_report' ? input.data : undefined,true,name==='agent_capabilities_report' ? 'PUT' : 'GET')};
@@ -76,7 +97,7 @@ export async function callAgent(name, args, config, profilePath) {
76
97
  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
98
  writeProfile(profilePath, { ...profile, url, pendingAgent: { id: data.id, secret: data.secret, credential: data.credential, expiresAt: data.expiresAt } });
78
99
  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.' } };
100
+ 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
101
  }
81
102
  const secret = profile.pendingAgent?.secret ?? profile.connectionSecret;
82
103
  if (!secret) throw new Error('No connection in this profile.');
@@ -90,24 +111,25 @@ export async function callAgent(name, args, config, profilePath) {
90
111
  return { data };
91
112
  }
92
113
  export async function agentCli(args) {
93
- const flags = {}; const positions = [];
114
+ const flags = {}; const positions = []; let json = false;
94
115
  for (let i = 0; i < args.length; i++) {
95
- if (args[i] === '--json') continue;
116
+ if (args[i] === '--json') { json = true; continue; }
96
117
  if (args[i].startsWith('--')) {
97
118
  const key = args[i].slice(2);
119
+ if (key === 'write') { flags.write = true; continue; }
98
120
  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.');
99
121
  flags[key] = args[++i];
100
122
  } else positions.push(args[i]);
101
123
  }
102
124
  let name = `agent_${positions[1]}`;
103
- let input = positions[1] === 'permission' ? {id:positions[2],scope:flags.scope} : positions[1] === 'connect' ? { name: flags.name, ...(flags.capabilities ? { capabilities: flags.capabilities.split(',').map(key => key.trim()).filter(Boolean) } : {}) } : positions[1] === 'revoke' ? { id: positions[2] } : {};
125
+ let input = positions[1] === 'permission' ? {id:positions[2],scope:flags.scope} : positions[1] === 'connect' ? { name: flags.name, ...(flags.write ? { write: true } : {}), ...(flags.capabilities ? { capabilities: flags.capabilities.split(',').map(key => key.trim()).filter(Boolean) } : {}) } : positions[1] === 'revoke' ? { id: positions[2] } : {};
104
126
  if(positions[1]==='accounts') {
105
127
  if(positions[2]==='show') {name=flags.id ? 'agent_account_authorizations_get' : 'agent_account_authorizations_show';input=flags.id ? {id:flags.id} : {};}
106
128
  else if(positions[2]==='authorize' && ['true','false'].includes(flags.authorized)) {name='agent_account_authorization_set';input={id:flags.id,provider:flags.provider,accountRef:flags.account,authorized:flags.authorized==='true'};}
107
129
  else throw new Error('Use agent accounts show [--id ID] or authorize --id ID --provider PROVIDER --account REF --authorized true|false.');
108
130
  }
109
131
  if (positions[1] === 'inventory') {
110
- if (positions[2] === 'report' && flags.file && !flags.id) {name='agent_inventory_report';input=readJsonFile(flags.file,32768,'Inventory file');}
132
+ 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
133
  else if (positions[2] === 'show' && !flags.file) {name=flags.id ? 'agent_inventory_get' : 'agent_inventory_show';input=flags.id ? {id:flags.id} : {};}
112
134
  else throw new Error('Use agent inventory report --file PATH or show [--id ID].');
113
135
  }
@@ -117,9 +139,29 @@ export async function agentCli(args) {
117
139
  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
140
  else if (positions[2] === 'report' && flags.file && !flags.id) {name='agent_capabilities_report';input=readCapabilityFile(flags.file);}
119
141
  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].');
142
+ else if (positions[2] === undefined && !flags.file && !flags.id) {name='agent_capabilities_list';input=flags.workspace ? {workspaceId:flags.workspace} : {};}
143
+ else throw new Error('Use agent capabilities [--workspace ID] [--json], report --file PATH or show [--id ID].');
121
144
  }
122
- console.log(JSON.stringify(await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), flags.profile)));
145
+ const result = await callAgent(name, input, configuration(flags.profile, agentManagementOperations.includes(name) ? 'agents:manage' : 'workspace:access'), profileFrom(flags.profile));
146
+ console.log(name === 'agent_capabilities_list' && !json ? capabilityText(result.data) : JSON.stringify(result));
147
+ }
148
+
149
+ /** Plain text for agent capabilities: own rows that work now, then rows that work with Audienti hosted tools. */
150
+ export function capabilityText(data) {
151
+ const rows = Array.isArray(data?.effective) ? data.effective : [];
152
+ 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' : ''}`;
153
+ const own = rows.filter(row => row.via !== 'audienti'), hosted = rows.filter(row => row.via === 'audienti');
154
+ return [
155
+ `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']),
156
+ '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']),
157
+ ...managedText(data?.managedAccounts),
158
+ ].join('\n');
159
+ }
160
+
161
+ /** #444: what each LinkedIn account connected through Audienti may do. Off means the agent may not do it, which is not a missing tool. */
162
+ function managedText(accounts) {
163
+ if (!Array.isArray(accounts) || !accounts.length) return [];
164
+ return ['Accounts connected through Audienti (what each action is set to):', ...accounts.flatMap(account => [` ${account.name ?? account.accountRef}`, ...(account.modes ?? []).map(mode => ` ${mode.label}: ${mode.modeName} (${mode.meaning})`)]), ' Off means the agent may not do it. Do not look for a tool for it.'];
123
165
  }
124
166
 
125
167
  function readCapabilityFile(path) {return readJsonFile(path,32768,'Capability file');}
@@ -0,0 +1,9 @@
1
+ import {z} from 'zod';
2
+ // #413: a file is uploaded first and named by id in a LinkedIn message (content.attachments). An image is at most 5 MB;
3
+ // a voice note is at most 2 minutes and 5 MB.
4
+ const uuid=z.string().uuid(),maxBytes=5*1024*1024;
5
+ const base64=z.string().min(4).max(4*Math.ceil(maxBytes/3)+8).refine(value=>value.length%4===0&&/^[A-Za-z0-9+/]+={0,2}$/.test(value));
6
+ export const attachmentOperations={
7
+ attachment_upload:{schema:z.object({workspaceId:uuid.optional(),name:z.string().trim().min(1).max(255),contentType:z.string().max(127),contentBase64:base64,durationSeconds:z.number().int().positive().max(120).optional()}).strict(),method:'POST',path:a=>`${a.workspaceId?`/workspaces/${a.workspaceId}`:''}/attachments`,body:a=>({name:a.name,contentType:a.contentType,contentBase64:a.contentBase64,...(a.durationSeconds?{durationSeconds:a.durationSeconds}:{})}),scoped:true,description:'Store an image (jpeg, png, gif, webp; at most 5 MB) or a voice note (mpeg, mp4, ogg, wav, webm; at most 5 MB and 2 minutes) and return its id. Name the id in content.attachments of a linkedin.send action tagged profile.message.send. A voice note needs durationSeconds and goes only to a connection. Needs workspace:access.'},
8
+ attachment_get:{schema:z.object({workspaceId:uuid.optional(),attachmentId:uuid}).strict(),method:'GET',path:a=>`${a.workspaceId?`/workspaces/${a.workspaceId}`:''}/attachments/${a.attachmentId}`,scoped:true,description:'One uploaded file with its content as base64. An agent that sends from its own LinkedIn account fetches the file here, then attaches it in LinkedIn.'},
9
+ };
@@ -0,0 +1,28 @@
1
+ import {readFileSync,writeFileSync,statSync} from 'node:fs';
2
+ import {basename,extname} from 'node:path';
3
+ import {callWorkspace,configuration} from './workspaces.mjs';
4
+ const help='Use attachments upload FILE [--type MIME] [--name NAME] [--duration SECONDS] [--workspace ID] [--profile PATH], or attachments get ATTACHMENT_ID --out PATH [--workspace ID] [--profile PATH]. An image is at most 5 MB; a voice note is at most 2 minutes and 5 MB, and its length goes in --duration.';
5
+ const TYPES={'.jpg':'image/jpeg','.jpeg':'image/jpeg','.png':'image/png','.gif':'image/gif','.webp':'image/webp','.mp3':'audio/mpeg','.m4a':'audio/mp4','.ogg':'audio/ogg','.wav':'audio/wav'};
6
+ export async function attachmentsCli(args){
7
+ const position=[],flags={};for(let i=1;i<args.length;i++){
8
+ if(!args[i].startsWith('--')){position.push(args[i]);continue}
9
+ const key=args[i].slice(2);if(key==='json')continue;if(!['profile','workspace','type','name','duration','out'].includes(key)||!args[i+1]||args[i+1].startsWith('--')||Object.hasOwn(flags,key))throw new Error(`Unknown or incomplete attachments option. ${help}`);
10
+ flags[key]=args[++i];
11
+ }
12
+ const config=configuration(flags.profile);
13
+ if(position[0]==='upload'&&position.length===2){
14
+ const file=position[1],type=flags.type??TYPES[extname(file).toLowerCase()];
15
+ if(!type)throw new Error(`Give --type for this file. ${help}`);
16
+ const stat=statSync(file);if(!stat.isFile()||stat.size>5*1024*1024)throw new Error('The file must be a regular file of at most 5 MB.');
17
+ const duration=flags.duration===undefined?undefined:Number(flags.duration);
18
+ if(duration!==undefined&&!Number.isInteger(duration))throw new Error('--duration is a whole number of seconds.');
19
+ const result=await callWorkspace('attachment_upload',{workspaceId:flags.workspace,name:flags.name??basename(file),contentType:type,contentBase64:readFileSync(file).toString('base64'),...(duration===undefined?{}:{durationSeconds:duration})},config);
20
+ console.log(JSON.stringify(result.data));return;
21
+ }
22
+ if(position[0]==='get'&&position.length===2&&flags.out){
23
+ const result=await callWorkspace('attachment_get',{workspaceId:flags.workspace,attachmentId:position[1]},config);
24
+ const {contentBase64,...rest}=result.data;writeFileSync(flags.out,Buffer.from(contentBase64,'base64'));
25
+ console.log(JSON.stringify({...rest,savedTo:flags.out}));return;
26
+ }
27
+ throw new Error(help);
28
+ }
@@ -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.'},