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

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