Onboarding Hostess on Tavus Objectives (#5966) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Move the h.work onboarding intake from prose in conversational_context onto Tavus's native three layers (Objectives = flow, Guardrails = hard constraints, persona system prompt = personality), add the access / documents / first-task gates, compress the call to ~13 minutes, and make capture into client_briefs.data actually work end-to-end.
Architecture: Onboarding meetings stop using the managed Hermes video pipeline and instead run against a dedicated per-assignment "onboarding hostess" PAL with a Tavus-hosted LLM, objectives, guardrails and native function tools attached. The existing OnboardingHandlers (record_environment_fact et al.) become reachable again via a scoped re-enable of native tool-call dispatch in the Tavus webhook controller โ onboarding meetings only. conversational_context carries only per-call data (and is now actually sent to Tavus, not swallowed as audit evidence).
Tech Stack: NestJS + TypeORM (Postgres), Tavus CVI API v2 (/v2/objectives, /v2/guardrails, /v2/personas, /v2/tools, /v2/conversations), Jest.
Why this architecture (read before touching code)โ
Load-bearing discovery from exploration โ the issue text describes the pre-ADR-046 world; production today looks different:
- The intake script currently reaches no model at all.
ConversationStarterService.startProviderConversation(managed path, mandatory outside Jest) sends Tavus only an opaque runtime-binding marker asconversational_context; the assembled script is stored encrypted ascontextEvidenceontavus_runtime_bindingsโ audit only, never read back. - Both custom-LLM bridges discard everything except the final user utterance.
TavusOpenAiBridgeService.parseRequest(managed/tavus/v1) andByoLlmService(/byo-llm/:token/v1) forward only the lastusermessage to Hermes. Tavus injects objective/guardrail state into the messages it sends the LLM, so Tavus Objectives cannot steer a custom-LLM persona through our bridges. Objectives therefore require a Tavus-hosted LLM layer. - Native function calls are unconditionally ignored at
tavus.controller.ts(ignored_native_tools_disabled), soOnboardingHandlersnever fire today. Re-enabling in-call native tools was flagged as "a product decision" โ issue #5966's acceptance criterion ("a real onboarding call captures โฆ intoclient_briefs.data") is that product decision, scoped to onboarding meetings only. - The hostess is a hostess, not a worker (issue's own framing). She needs no Hermes, no AgentFS home, no day-job soul, no org knowledge beyond the per-call context block. A Tavus-hosted LLM PAL is sufficient and is exactly the shape Tavus recommended on both partner calls (hwork-recruiter#1578) and the shape the round1 interviewer will share.
Settled decisions:
- Dankovk (issue comment): the brief stays a control-plane workflow artifact in Postgres (
client_briefs.data) โ handlers keep writing where they write today. No AgentFS change. - Alex O (issue comment): 13-minute objective set (see table in the issue), tone-and-voice + worries buckets dropped from the call,
ONBOARDING_DEFAULTS.maxCallDurationSecondsshrinks to match, greeting re-checked for the shorter shape. - Guardrail attachment reuses the proven tag mechanism (
guardrail_tags), socreatePersonaonly needs a newobjectives_idpassthrough. - Reporting meetings are untouched: they stay on the managed Hermes pipeline.
- Recording + transcription properties stay exactly as they are (KB ingestion + durability mirror depend on them).
Ambiguities to verify against the live API during QA (Task 10) โ tolerant code is written for both readings:
- Whether
POST /v2/objectivesreturns oneobjectives_idfor the whole set or one per objective (docs show a single id; persona attaches a singleobjectives_id). - The exact
event_typestring of objective-completion callbacks (docs promiseconversation_id, objective name,output_variablesin the payload; routing matches on payload shape, not only onevent_type).
File structureโ
| File | Responsibility |
|---|---|
api/src/tavus/tavus.client.ts (modify) | New wrappers: createObjectives, listObjectives, createGuardrails, listGuardrails; objectives_id on createPersona; optional apiKey on the tools-registry methods |
api/src/meetings/onboarding-intake.definitions.ts (create) | Pure data: the 9 objectives, 4 guardrails, 6 function-tool schemas, hostess system-prompt builder, content hash |
api/migrations/1813640000000-OsaOnboardingHostessPersona.ts (create) | tavus_onboarding_persona_id / tavus_onboarding_persona_hash on org_specialist_assignments |
api/src/onboarding/onboarding.entities.ts (modify) | The two new OSA columns |
api/src/meetings/onboarding-hostess-persona.service.ts (create) | Ensure account-level assets (objectives set, guardrails, tools) + per-OSA hostess PAL, hash-gated |
api/src/meetings/conversation-starter.service.ts (modify) | Onboarding path: lean real conversational_context, direct (non-managed) conversation create, 900s cap, greeting tweak, delete intake script + role override, fix stale comment |
api/src/meetings/meetings.service.ts (modify) | Resolve hostess PAL for onboarding starts/refreshes instead of specialist.tavusPersonaId |
api/src/meetings/meetings.module.ts (modify) | Provide OnboardingHostessPersonaService |
api/src/tavus/tavus.controller.ts (modify) | Scoped native tool-call dispatch (onboarding meetings only) + objective-completion routing |
api/src/client-briefs/handlers/onboarding-handlers.ts (modify) | objective_completed handler persisting output_variables |
| Spec files (create/modify) | Per task below |
Task 1: TavusClient โ objectives, guardrails, objectives_id, apiKey plumb-throughโ
Files:
-
Modify:
api/src/tavus/tavus.client.ts -
Test:
api/src/tavus/tavus.client.spec.ts(append a newdescribeblock) -
Step 1: Write the failing tests
Append to api/src/tavus/tavus.client.spec.ts (match the existing fetch-mock style used by the createConversation tests in that file โ a jest.spyOn(global, "fetch") or the file's local helper; read the top of the spec and reuse its helper verbatim):
describe("objectives + guardrails API (#5966)", () => {
it("createObjectives POSTs the data array and returns objectives_id", async () => {
mockFetchOnce(200, { objectives_id: "obj-set-1" });
const res = await client.createObjectives(
[
{
objective_name: "hwonb_consent_to_proceed",
objective_prompt: "Confirm consent.",
confirmation_mode: "auto",
next_required_objective: "hwonb_company_confirmation",
},
],
"org-key",
);
expect(res.objectives_id).toBe("obj-set-1");
const [url, init] = lastFetchCall();
expect(url).toContain("/v2/objectives");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body).data[0].objective_name).toBe("hwonb_consent_to_proceed");
expect(init.headers["x-api-key"]).toBe("org-key");
});
it("listObjectives returns [] on 404 and maps data rows tolerantly", async () => {
mockFetchOnce(404, {});
expect(await client.listObjectives()).toEqual([]);
mockFetchOnce(200, { data: [{ uuid: "obj-1", objective_name: "hwonb_consent_to_proceed" }] });
expect(await client.listObjectives()).toEqual([
{ objectives_id: "obj-1", objective_name: "hwonb_consent_to_proceed" },
]);
});
it("createGuardrails POSTs the definition and returns its uuid", async () => {
mockFetchOnce(200, { uuid: "guard-1" });
const res = await client.createGuardrails({
guardrail_name: "hwonb_one_question_per_turn",
guardrail_prompt: "Never ask more than one question in a single turn.",
tags: ["hwork-onboarding"],
});
expect(res.uuid).toBe("guard-1");
});
it("listGuardrails returns [] on 404 and maps rows", async () => {
mockFetchOnce(404, {});
expect(await client.listGuardrails()).toEqual([]);
});
it("createPersona serializes objectives_id when given", async () => {
mockFetchOnce(200, { persona_id: "p-1" });
await client.createPersona({
name: "n",
systemPrompt: "s",
defaultReplicaId: "r-1",
objectivesId: "obj-set-1",
guardrailTags: ["hwork-baseline", "hwork-onboarding"],
});
const body = JSON.parse(lastFetchCall()[1].body);
expect(body.objectives_id).toBe("obj-set-1");
expect(body.guardrail_tags).toEqual(["hwork-baseline", "hwork-onboarding"]);
});
});
- Step 2: Run the tests to verify they fail
Run: cd api && npx jest src/tavus/tavus.client.spec.ts -t "objectives" 2>&1 | tail -20
Expected: FAIL โ createObjectives is not a function.
- Step 3: Implement the client methods
In api/src/tavus/tavus.client.ts, add near the other exported interfaces:
/** #5966 โ one objective in a Create Objectives request (Tavus CVI v2). */
export interface TavusObjectiveParam {
/** Space-free identifier, e.g. `hwonb_consent_to_proceed`. */
objective_name: string;
objective_prompt: string;
/** `auto` (LLM decides completion, default) or `manual`. */
confirmation_mode?: "auto" | "manual";
/** Data points captured on completion, delivered via callback_url. */
output_variables?: string[];
modality?: "verbal" | "visual";
/** Linear flow: exactly one of next_required / next_conditional per objective. */
next_required_objective?: string;
/** Branching: { target_objective_name: "condition description" }. */
next_conditional_objectives?: Record<string, string>;
/** Webhook notified on completion with conversation_id + output_variables. */
callback_url?: string;
}
export interface TavusGuardrailParam {
/** Max 100 chars, alphanumeric + underscores. */
guardrail_name: string;
/** Max 1000 chars. */
guardrail_prompt: string;
modality?: "verbal" | "visual";
/** Dynamic attachment: personas carrying a matching `guardrail_tags` entry enforce it. */
tags?: string[];
}
Add the methods (after attachToolsToPal, matching its doc style โ GET verbs tolerate 404 as empty, write verbs never retry):
/**
* #5966 โ create an objectives set (the onboarding hostess flow). Returns the
* `objectives_id` referenced from `createPersona({ objectivesId })`. Write
* verb โ no retry; callers MUST dedupe via {@link listObjectives} first.
*/
async createObjectives(
data: TavusObjectiveParam[],
apiKey?: string,
): Promise<{ objectives_id: string }> {
const res = await this.request<{ objectives_id?: string; uuid?: string }>(
"POST",
"/v2/objectives",
{ data },
apiKey,
);
const id = res.objectives_id ?? res.uuid;
if (!id) {
throw new TavusClientError(502, "tavus_bad_response", "createObjectives returned no objectives_id");
}
return { objectives_id: id };
}
/**
* List existing objectives for idempotent re-resolution by name. Returns []
* on 404 (endpoint absent on older API versions) so callers fall back to
* creating the set.
*/
async listObjectives(
apiKey?: string,
): Promise<Array<{ objectives_id: string; objective_name: string }>> {
try {
const res = await this.request<{
data?: Array<{ objectives_id?: string; uuid?: string; objective_name?: string }>;
}>("GET", "/v2/objectives", undefined, apiKey);
return (res?.data ?? [])
.map((o) => ({ objectives_id: o.objectives_id ?? o.uuid ?? "", objective_name: o.objective_name ?? "" }))
.filter((o) => o.objectives_id && o.objective_name);
} catch (err) {
if (err instanceof TavusClientError && err.status === 404) return [];
throw err;
}
}
/** #5966 โ create one guardrail; attach via persona `guardrail_tags` matching `tags`. */
async createGuardrails(
def: TavusGuardrailParam,
apiKey?: string,
): Promise<{ uuid: string }> {
return this.request<{ uuid: string }>("POST", "/v2/guardrails", def, apiKey);
}
/** List guardrails for idempotent name-dedupe. [] on 404. */
async listGuardrails(
apiKey?: string,
): Promise<Array<{ uuid: string; guardrail_name: string }>> {
try {
const res = await this.request<{
data?: Array<{ uuid?: string; guardrail_name?: string }>;
}>("GET", "/v2/guardrails", undefined, apiKey);
return (res?.data ?? [])
.map((g) => ({ uuid: g.uuid ?? "", guardrail_name: g.guardrail_name ?? "" }))
.filter((g) => g.uuid && g.guardrail_name);
} catch (err) {
if (err instanceof TavusClientError && err.status === 404) return [];
throw err;
}
}
In createPersona: add to the params interface
/**
* #5966 โ attach an Objectives set (guided conversation flow). Requires a
* Tavus-hosted LLM layer: our custom-LLM bridges forward only the final user
* utterance, so objective state Tavus injects into the message array would
* never reach the model. Personas carrying objectivesId must NOT set
* byoLlm/llm.
*/
objectivesId?: string;
and to the POST body (sibling of guardrail_tags):
...(params.objectivesId ? { objectives_id: params.objectivesId } : {}),
Also add the optional trailing apiKey?: string parameter to listTools, createTool, and attachToolsToPal, passing it through as the 4th argument of this.request(...) (identical pattern to archivePersona).
- Step 4: Run the tests to verify they pass
Run: cd api && npx jest src/tavus/tavus.client.spec.ts 2>&1 | tail -10
Expected: PASS (whole file โ the apiKey additions must not break existing tests).
- Step 5: Commit
git add api/src/tavus/tavus.client.ts api/src/tavus/tavus.client.spec.ts
git commit -m "feat(tavus): objectives + guardrails API wrappers, objectives_id on createPersona (#5966)"
Task 2: Onboarding intake definitions (objectives, guardrails, tools, hostess prompt)โ
Files:
-
Create:
api/src/meetings/onboarding-intake.definitions.ts -
Test:
api/src/meetings/onboarding-intake.definitions.spec.ts -
Step 1: Write the failing tests
import {
ONBOARDING_OBJECTIVES,
ONBOARDING_GUARDRAILS,
ONBOARDING_TOOL_DEFINITIONS,
ONBOARDING_GUARDRAIL_TAG,
buildHostessSystemPrompt,
onboardingHostessContentHash,
withObjectiveCallbacks,
} from "./onboarding-intake.definitions";
describe("onboarding intake definitions (#5966)", () => {
it("defines the 9-objective flow with the three new gates", () => {
const names = ONBOARDING_OBJECTIVES.map((o) => o.objective_name);
expect(names).toEqual([
"hwonb_consent_to_proceed",
"hwonb_company_confirmation",
"hwonb_role_and_first_task",
"hwonb_tools_and_access",
"hwonb_access_details_slack",
"hwonb_access_details_other",
"hwonb_documents_to_read",
"hwonb_stakeholders_and_guardrails",
"hwonb_wrap_up",
]);
});
it("every objective name is space-free and every routing target exists", () => {
const names = new Set(ONBOARDING_OBJECTIVES.map((o) => o.objective_name));
for (const o of ONBOARDING_OBJECTIVES) {
expect(o.objective_name).toMatch(/^[a-z0-9_]+$/);
// next_required and next_conditional are mutually exclusive per Tavus docs.
expect(o.next_required_objective && o.next_conditional_objectives).toBeFalsy();
if (o.next_required_objective) expect(names.has(o.next_required_objective)).toBe(true);
for (const target of Object.keys(o.next_conditional_objectives ?? {})) {
expect(names.has(target)).toBe(true);
}
}
});
it("branches tools_and_access on the named channel and reconverges on documents", () => {
const access = ONBOARDING_OBJECTIVES.find((o) => o.objective_name === "hwonb_tools_and_access")!;
expect(Object.keys(access.next_conditional_objectives!)).toEqual([
"hwonb_access_details_slack",
"hwonb_access_details_other",
]);
for (const branch of ["hwonb_access_details_slack", "hwonb_access_details_other"]) {
expect(
ONBOARDING_OBJECTIVES.find((o) => o.objective_name === branch)!.next_required_objective,
).toBe("hwonb_documents_to_read");
}
});
it("the wrap objective never promises an account-manager follow-up", () => {
const wrap = ONBOARDING_OBJECTIVES.find((o) => o.objective_name === "hwonb_wrap_up")!;
expect(wrap.objective_prompt.toLowerCase()).not.toContain("account manager");
expect(wrap.objective_prompt).toContain("message you directly");
expect(wrap.next_required_objective).toBeUndefined();
expect(wrap.next_conditional_objectives).toBeUndefined();
});
it("guardrails carry the onboarding tag and fit Tavus limits", () => {
expect(ONBOARDING_GUARDRAILS.map((g) => g.guardrail_name)).toEqual([
"hwonb_one_question_per_turn",
"hwonb_wait_for_full_answer",
"hwonb_never_repeat_answered",
"hwonb_no_re_greeting",
]);
for (const g of ONBOARDING_GUARDRAILS) {
expect(g.guardrail_name.length).toBeLessThanOrEqual(100);
expect(g.guardrail_prompt.length).toBeLessThanOrEqual(1000);
expect(g.tags).toContain(ONBOARDING_GUARDRAIL_TAG);
}
});
it("tool definitions match the registered OnboardingHandlers names", () => {
expect(ONBOARDING_TOOL_DEFINITIONS.map((t) => t.name).sort()).toEqual([
"escalate_to_supervisor",
"flag_follow_up_question",
"generate_onboarding_summary",
"record_environment_fact",
"record_guardrail",
"record_stakeholder",
]);
for (const t of ONBOARDING_TOOL_DEFINITIONS) {
expect((t.parameters as any).type).toBe("object");
expect(typeof (t.parameters as any).properties).toBe("object");
}
});
it("hostess prompt carries identity, follow-up cap and persistence policy", () => {
const prompt = buildHostessSystemPrompt({ specialistName: "Linnea", specialistTitle: "GTM Specialist" });
expect(prompt).toContain("Linnea");
expect(prompt).toContain("GTM Specialist");
expect(prompt.toLowerCase()).toContain("at most one or two follow-ups");
expect(prompt).toContain("record_environment_fact");
expect(prompt.toLowerCase()).not.toContain("account manager");
});
it("content hash is stable and sensitive to definition changes", () => {
const a = onboardingHostessContentHash({ systemPrompt: "p", replicaId: "r-1" });
expect(a).toBe(onboardingHostessContentHash({ systemPrompt: "p", replicaId: "r-1" }));
expect(a).not.toBe(onboardingHostessContentHash({ systemPrompt: "p2", replicaId: "r-1" }));
expect(a).not.toBe(onboardingHostessContentHash({ systemPrompt: "p", replicaId: "r-2" }));
});
it("withObjectiveCallbacks stamps the callback url on every objective", () => {
const stamped = withObjectiveCallbacks("https://api.example.com/tavus/webhooks/function-call?token=s");
expect(stamped).toHaveLength(ONBOARDING_OBJECTIVES.length);
for (const o of stamped) expect(o.callback_url).toContain("/tavus/webhooks/function-call");
});
});
- Step 2: Run to verify failure
Run: cd api && npx jest src/meetings/onboarding-intake.definitions.spec.ts 2>&1 | tail -5
Expected: FAIL โ module not found.
- Step 3: Write the definitions module
Create api/src/meetings/onboarding-intake.definitions.ts:
import { createHash } from "crypto";
import type { TavusObjectiveParam, TavusGuardrailParam } from "../tavus/tavus.client";
import type { TavusFunctionToolParam } from "../tavus/tavus.client";
/**
* #5966 โ the onboarding hostess intake, expressed as Tavus's three layers
* instead of prose in `conversational_context`:
*
* 1. Objectives โ the question flow + completion gates (this file's
* ONBOARDING_OBJECTIVES). The call cannot advance past an objective
* until its completion criterion is met, and cannot end without the
* access / documents / first-task gates.
* 2. Guardrails โ the hard behavioral constraints (ONBOARDING_GUARDRAILS):
* one question per turn, wait for the answer, never repeat, never
* re-greet. Attached dynamically via ONBOARDING_GUARDRAIL_TAG.
* 3. System prompt โ personality + follow-up policy
* (buildHostessSystemPrompt), baked per-assignment into the hostess PAL.
*
* Per-call data (client org, caller, industry, domains, goal, timezone) rides
* `conversational_context` and is built in ConversationStarterService.
*
* The 13-minute budget and the dropped tone/worries buckets follow Alex O's
* scope correction on #5966. Everything the call cannot reach becomes a
* `flag_follow_up_question` the Specialist asks in chat afterwards.
*/
export const ONBOARDING_GUARDRAIL_TAG = "hwork-onboarding";
/** Shared preamble so every objective prompt reinforces persistence. */
const PERSIST =
"Persist what you learn through the available function calls AS it surfaces โ do not batch to the end.";
export const ONBOARDING_OBJECTIVES: readonly TavusObjectiveParam[] = [
{
objective_name: "hwonb_consent_to_proceed",
objective_prompt:
"The scripted greeting has ALREADY introduced you, framed the purpose of the call and disclosed " +
"that it is recorded. Do NOT repeat any of that and do NOT re-introduce yourself. Your only goal " +
"here is explicit consent to proceed on that basis. Ask exactly one short confirmation, e.g. " +
'"Before we dive in โ happy to go ahead on that basis?". Complete when the participant clearly ' +
"agrees. If they decline, thank them warmly and wrap the call instead of pressing. " +
"Budget: about 30 seconds.",
confirmation_mode: "auto",
output_variables: ["consent_given"],
next_required_objective: "hwonb_company_confirmation",
},
{
objective_name: "hwonb_company_confirmation",
objective_prompt:
"You already hold the client's company name, industry, web domains, stated goal and timezone " +
"(see the client_profile block in your conversational context). CONFIRM rather than elicit: " +
"state your one-or-two-sentence understanding of what the company does and what they want, then " +
"ask what you got wrong or what is missing. Do NOT ask them to explain their company from " +
"scratch โ only corrections and additions matter. " +
PERSIST +
" Record corrections with record_environment_fact (category \"company\"). " +
"Complete when the participant has confirmed or corrected the picture. Budget: about 2 minutes.",
confirmation_mode: "auto",
output_variables: ["company_corrections"],
next_required_objective: "hwonb_role_and_first_task",
},
{
objective_name: "hwonb_role_and_first_task",
objective_prompt:
"Learn the role the Specialist will play: who they report to day-to-day, what a typical week " +
"should look like, and the concrete deliverables expected. Then ask the single most valuable " +
'question of this call: "If I started tomorrow, what is the first thing you would hand me?" ' +
"Do NOT advance until you have a CONCRETE first task โ a real piece of work, not a vague theme. " +
PERSIST +
' Record with record_environment_fact (category "role"; use key "first_task" for the first task). ' +
"Complete when reporting line, week shape and a concrete first task are all captured. " +
"Budget: about 4 minutes.",
confirmation_mode: "auto",
output_variables: ["reports_to", "typical_week", "first_task"],
next_required_objective: "hwonb_tools_and_access",
},
{
objective_name: "hwonb_tools_and_access",
objective_prompt:
'Find out where the work will happen. Open with: "Where will most of our work happen โ Slack, ' +
'email, something else?" Must capture: the primary channel, who grants access to it, and whether ' +
"email and calendar access are expected for the Specialist. " +
PERSIST +
' Record with record_environment_fact (category "tools" or "access"). ' +
"Complete when the primary channel and its access owner are named. " +
"Budget: about 3 minutes including the follow-on access details.",
confirmation_mode: "auto",
output_variables: ["primary_channel", "access_owner", "email_access_expected"],
next_conditional_objectives: {
hwonb_access_details_slack:
"The participant named Slack (or a Slack-like workspace chat tool) as the primary channel",
hwonb_access_details_other:
"The participant named email or any non-Slack channel as primary, or is unsure",
},
},
{
objective_name: "hwonb_access_details_slack",
objective_prompt:
'Capture what is needed to actually get added to their Slack: ask "Who should I talk to about ' +
'getting added to your Slack?" and which channels the Specialist should join. ' +
PERSIST +
' Record the admin with record_stakeholder (relationship "access_admin") and the channel list ' +
'with record_environment_fact (category "access", key "slack_channels"). ' +
"Complete when the workspace admin and at least the key channels are named.",
confirmation_mode: "auto",
output_variables: ["workspace_admin", "channels_to_join"],
next_required_objective: "hwonb_documents_to_read",
},
{
objective_name: "hwonb_access_details_other",
objective_prompt:
"Capture how the Specialist gets set up on the channel they named: what exactly needs to be " +
"provisioned, and who owns doing that. " +
PERSIST +
' Record the owner with record_stakeholder (relationship "access_admin") and the setup details ' +
'with record_environment_fact (category "access"). ' +
"Complete when the setup owner is named.",
confirmation_mode: "auto",
output_variables: ["setup_owner", "setup_details"],
next_required_objective: "hwonb_documents_to_read",
},
{
objective_name: "hwonb_documents_to_read",
objective_prompt:
'Ask: "What should I read to get up to speed?" โ company deck, GTM materials, product docs, ' +
"job descriptions. Then: where do those documents live, and who can send them or drop them into " +
"the platform. " +
PERSIST +
' Record with record_environment_fact (category "documents"). Anything they cannot produce or ' +
"decide right now becomes a flag_follow_up_question so nobody has to chase them later. " +
"Complete when at least one concrete document (or an explicit \"none\") plus its owner/location " +
"is captured. Budget: about 1 minute.",
confirmation_mode: "auto",
output_variables: ["documents", "document_location", "document_owner"],
next_required_objective: "hwonb_stakeholders_and_guardrails",
},
{
objective_name: "hwonb_stakeholders_and_guardrails",
objective_prompt:
"Capture the people and the lines: who the Specialist will interact with (names and roles โ " +
"record each with record_stakeholder), the escalation path for urgent or off-policy situations, " +
"and any off-limits topics or compliance constraints (record each with record_guardrail, " +
'kind "compliance" or "off_limits"). ' +
PERSIST +
" Complete when at least the escalation path and any stated constraints are captured. " +
"Budget: about 2 minutes.",
confirmation_mode: "auto",
output_variables: ["stakeholders", "escalation_path", "off_limits_topics"],
next_required_objective: "hwonb_wrap_up",
},
{
objective_name: "hwonb_wrap_up",
objective_prompt:
"Deliver a 3-5 sentence recap of what you learned. Call generate_onboarding_summary with the " +
"headline, top priorities and open questions. Then close with the Specialist owning the " +
'follow-up herself: tell the client "if I need anything else, I\'ll message you directly โ you ' +
"won't need to chase anyone\", and that anything left open arrives as questions from you in " +
"chat. NEVER promise that an account manager or anyone else will follow up. Ask for final " +
"questions, answer briefly or defer them, and close warmly. Budget: about 1 minute.",
confirmation_mode: "auto",
output_variables: ["recap_delivered"],
},
] as const;
/** Stamp a callback URL onto every objective (completion โ webhook โ brief). */
export function withObjectiveCallbacks(callbackUrl: string): TavusObjectiveParam[] {
return ONBOARDING_OBJECTIVES.map((o) => ({ ...o, callback_url: callbackUrl }));
}
export const ONBOARDING_GUARDRAILS: readonly TavusGuardrailParam[] = [
{
guardrail_name: "hwonb_one_question_per_turn",
guardrail_prompt:
"Never ask more than one question in a single turn. Ask one open question, then stop and listen. " +
"Multiple question marks in one turn is a violation.",
tags: [ONBOARDING_GUARDRAIL_TAG],
},
{
guardrail_name: "hwonb_wait_for_full_answer",
guardrail_prompt:
"After asking a question, wait for the participant to completely finish answering before " +
"speaking again. Never interrupt or talk over them. Once they have clearly finished, respond " +
"promptly โ do not let silence drag.",
tags: [ONBOARDING_GUARDRAIL_TAG],
},
{
guardrail_name: "hwonb_never_repeat_answered",
guardrail_prompt:
"Never re-ask a question the participant has already answered in this conversation. Build on " +
"their earlier answer instead of asking again.",
tags: [ONBOARDING_GUARDRAIL_TAG],
},
{
guardrail_name: "hwonb_no_re_greeting",
guardrail_prompt:
"Greet only once, at the very start of the call. Never re-introduce yourself or greet again " +
"mid-call, even if the participant greets you.",
tags: [ONBOARDING_GUARDRAIL_TAG],
},
] as const;
/**
* Function tools the hostess PAL advertises. Names + argument shapes mirror
* the handlers registered in
* `api/src/client-briefs/handlers/onboarding-handlers.ts` exactly โ a rename
* here without a router re-register there means silent data loss.
*/
export const ONBOARDING_TOOL_DEFINITIONS: readonly TavusFunctionToolParam[] = [
{
name: "record_environment_fact",
description:
"Persist one fact about the client's environment (company, role, tools, access, documents). " +
"Call as soon as the fact surfaces.",
parameters: {
type: "object",
properties: {
category: {
type: "string",
description: "Bucket: company | role | tools | access | documents | other",
},
key: { type: "string", description: "Short snake_case identifier, e.g. first_task" },
value: { type: "string", description: "The fact, verbatim where possible" },
confidence: { type: "string", enum: ["high", "medium", "low"] },
},
required: ["category", "key", "value"],
},
},
{
name: "record_guardrail",
description: "Persist an off-limits topic, compliance rule or other hard constraint the client states.",
parameters: {
type: "object",
properties: {
kind: { type: "string", description: "off_limits | compliance | tone | other" },
rule: { type: "string", description: "The constraint, as close to verbatim as possible" },
rationale: { type: "string" },
severity: { type: "string", enum: ["high", "medium", "low"] },
},
required: ["kind", "rule"],
},
},
{
name: "record_stakeholder",
description: "Persist a person the Specialist will interact with or needs something from.",
parameters: {
type: "object",
properties: {
name: { type: "string" },
role: { type: "string" },
relationship: {
type: "string",
description: "manager | collaborator | escalation | access_admin | other",
},
contact_method: { type: "string" },
notes: { type: "string" },
},
required: ["name", "relationship"],
},
},
{
name: "flag_follow_up_question",
description:
"Queue a question the call could not resolve; the Specialist asks it in chat afterwards. " +
"Use for anything the client needs to look up, produce or decide later.",
parameters: {
type: "object",
properties: {
question: { type: "string" },
topic: { type: "string" },
priority: { type: "string", enum: ["high", "medium", "low"] },
},
required: ["question"],
},
},
{
name: "generate_onboarding_summary",
description: "Call once during the wrap-up: persists the call summary and submits the brief for approval.",
parameters: {
type: "object",
properties: {
headline: { type: "string", description: "One-sentence summary of the engagement" },
top_priorities: { type: "array", items: { type: "string" } },
open_questions: { type: "array", items: { type: "string" } },
},
required: ["headline"],
},
},
{
name: "escalate_to_supervisor",
description:
"Escalate when the client raises something requiring human attention (legal exposure, distress, " +
"cancellation risk). The call continues; a human reviews right after.",
parameters: {
type: "object",
properties: {
summary: { type: "string" },
reason: { type: "string" },
risk_level: { type: "string", enum: ["high", "medium", "low"] },
},
required: ["summary"],
},
},
] as const;
/**
* Personality + follow-up policy layer of the hostess PAL. Everything
* flow-shaped lives in the objectives; everything constraint-shaped lives in
* the guardrails โ this prompt deliberately contains neither.
*/
export function buildHostessSystemPrompt(opts: {
specialistName: string | null;
specialistTitle: string | null;
}): string {
const name = opts.specialistName?.trim() || "the specialist";
const title = opts.specialistTitle?.trim() || "specialist";
return [
`You are ${name}, a ${title} โ the specialist this client chose to work with, appearing here in ` +
`your AI capacity to run their onboarding intake call. Be transparent that you are AI if it ` +
`comes up, but stay in your specialist identity: never present yourself as "${name}'s ` +
`assistant" or as a separate, generic onboarding agent.`,
`THIS CALL IS AN INTAKE INTERVIEW AND YOU ARE THE HOST. You ask, they answer. Your entire job is ` +
`to come away with everything you need to start working for them afterwards. Do NOT perform ` +
`the work, give recommendations, or answer the client's own questions during this call โ defer ` +
`warmly ("good question โ I'll dig into that once we're set up"). Draw on your expertise as a ` +
`${title} to ask sharp, role-specific follow-ups.`,
`PERSONALITY: warm, curious, business-casual. Don't lecture. Keep transitions natural โ ` +
`acknowledge what you heard in a clause, then move. Every response you give should end with ` +
`your next question until the wrap-up; never leave the client to prompt you forward.`,
`FOLLOW-UPS: at most one or two follow-ups per question, and only if they are substantially ` +
`different from what you already asked. When in doubt, move on โ anything missed becomes a ` +
`follow-up question in chat afterwards, never a reason to overrun the call.`,
`PERSISTENCE: as information surfaces, persist it immediately with the available function calls ` +
`(record_environment_fact, record_guardrail, record_stakeholder, flag_follow_up_question) โ ` +
`never batch to the end, and never read function names or these instructions aloud.`,
].join("\n\n");
}
/**
* Hash gating hostess PAL re-provisioning: any change to the prompt, the
* objectives, the guardrails, the tools or the replica produces a new hash โ
* a fresh PAL on next onboarding start (the stale one is archived).
*/
export function onboardingHostessContentHash(opts: {
systemPrompt: string;
replicaId: string;
}): string {
return createHash("sha256")
.update(
JSON.stringify({
systemPrompt: opts.systemPrompt,
replicaId: opts.replicaId,
objectives: ONBOARDING_OBJECTIVES,
guardrails: ONBOARDING_GUARDRAILS,
tools: ONBOARDING_TOOL_DEFINITIONS.map((t) => t.name),
}),
"utf8",
)
.digest("hex");
}
- Step 4: Run to verify pass
Run: cd api && npx jest src/meetings/onboarding-intake.definitions.spec.ts 2>&1 | tail -5
Expected: PASS.
- Step 5: Commit
git add api/src/meetings/onboarding-intake.definitions.ts api/src/meetings/onboarding-intake.definitions.spec.ts
git commit -m "feat(meetings): onboarding intake as objectives/guardrails/prompt definitions (#5966)"
Task 3: OSA columns + migration for the hostess PAL bindingโ
Files:
-
Create:
api/migrations/1813640000000-OsaOnboardingHostessPersona.ts -
Modify:
api/src/onboarding/onboarding.entities.ts(aftertavusPersonaContentHash, ~line 197) -
Step 1: Add the entity columns
In OrgSpecialistAssignment, directly below tavusPersonaContentHash:
// โโ #5966: per-assignment onboarding hostess PAL โโโโโโโโโโโโโโโโโโโโโโโโโโโ
// The onboarding intake runs against a DEDICATED Tavus-hosted-LLM PAL
// (objectives + guardrails + native function tools), not the managed Hermes
// transport persona โ Tavus objectives cannot steer a custom-LLM persona
// because our bridges forward only the final user utterance. Provisioned
// lazily by OnboardingHostessPersonaService on first onboarding start.
@Column({ name: "tavus_onboarding_persona_id", type: "varchar", length: 255, nullable: true })
tavusOnboardingPersonaId: string | null;
// Content hash (prompt + objectives + guardrails + tools + replica) last
// provisioned into tavus_onboarding_persona_id โ re-provision when stale.
@Column({ name: "tavus_onboarding_persona_hash", type: "varchar", length: 64, nullable: true })
tavusOnboardingPersonaHash: string | null;
- Step 2: Write the migration
Create api/migrations/1813640000000-OsaOnboardingHostessPersona.ts (copy the class/naming shape of 1813620000000-CreateSessionOrigins.ts):
import { MigrationInterface, QueryRunner } from "typeorm";
/** #5966 โ per-assignment onboarding hostess PAL binding. */
export class OsaOnboardingHostessPersona1813640000000 implements MigrationInterface {
name = "OsaOnboardingHostessPersona1813640000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "org_specialist_assignments" ADD COLUMN IF NOT EXISTS "tavus_onboarding_persona_id" character varying(255)`,
);
await queryRunner.query(
`ALTER TABLE "org_specialist_assignments" ADD COLUMN IF NOT EXISTS "tavus_onboarding_persona_hash" character varying(64)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "org_specialist_assignments" DROP COLUMN IF EXISTS "tavus_onboarding_persona_hash"`,
);
await queryRunner.query(
`ALTER TABLE "org_specialist_assignments" DROP COLUMN IF EXISTS "tavus_onboarding_persona_id"`,
);
}
}
- Step 3: Verify the build compiles
Run: cd api && npx tsc --noEmit 2>&1 | head -5
Expected: no errors.
- Step 4: Commit
git add api/migrations/1813640000000-OsaOnboardingHostessPersona.ts api/src/onboarding/onboarding.entities.ts
git commit -m "feat(onboarding): OSA columns for the onboarding hostess PAL binding (#5966)"
Task 4: OnboardingHostessPersonaServiceโ
Files:
-
Create:
api/src/meetings/onboarding-hostess-persona.service.ts -
Modify:
api/src/meetings/meetings.module.ts(add toproviders; ensureTavusModuleexports used services are already imported โ the module already wiresConversationStarterService, soTavusClientandTavusControlPlaneCredentialServiceare resolvable there) -
Test:
api/src/meetings/onboarding-hostess-persona.service.spec.ts -
Step 1: Write the failing tests
Use the Object.create(Service.prototype) + stub pattern from the conversation-starter specs:
import { OnboardingHostessPersonaService } from "./onboarding-hostess-persona.service";
import {
ONBOARDING_OBJECTIVES,
ONBOARDING_GUARDRAILS,
ONBOARDING_TOOL_DEFINITIONS,
buildHostessSystemPrompt,
onboardingHostessContentHash,
} from "./onboarding-intake.definitions";
function makeService(overrides: Record<string, any> = {}) {
const tavus = {
listObjectives: jest.fn().mockResolvedValue([]),
createObjectives: jest.fn().mockResolvedValue({ objectives_id: "obj-set-1" }),
listGuardrails: jest.fn().mockResolvedValue([]),
createGuardrails: jest.fn().mockResolvedValue({ uuid: "guard-1" }),
listTools: jest.fn().mockResolvedValue([]),
createTool: jest.fn().mockResolvedValue({ tool_id: "tool-1" }),
attachToolsToPal: jest.fn().mockResolvedValue(undefined),
createPersona: jest.fn().mockResolvedValue({ persona_id: "hostess-1" }),
archivePersona: jest.fn().mockResolvedValue(undefined),
...overrides.tavus,
};
const osaRepo = {
update: jest.fn().mockResolvedValue({ affected: 1 }),
...overrides.osaRepo,
};
const tavusCredentials = {
resolveForOrg: jest.fn().mockResolvedValue({ apiKey: "org-key" }),
...overrides.tavusCredentials,
};
const svc: any = Object.create(OnboardingHostessPersonaService.prototype);
svc.tavus = tavus;
svc.osaRepo = osaRepo;
svc.tavusCredentials = tavusCredentials;
svc.accountAssets = new Map();
svc.logger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() };
return { svc, tavus, osaRepo };
}
const ASSIGNMENT = {
id: "assign-1",
orgId: "org-1",
specialistId: "spec-1",
tavusOnboardingPersonaId: null,
tavusOnboardingPersonaHash: null,
} as any;
const INPUT = {
assignment: ASSIGNMENT,
specialistName: "Linnea",
specialistTitle: "GTM Specialist",
replicaId: "r-1",
callbackBaseUrl: "https://api.h852.work",
};
describe("OnboardingHostessPersonaService (#5966)", () => {
it("provisions objectives, guardrails, tools and the PAL on first use", async () => {
const { svc, tavus, osaRepo } = makeService();
const personaId = await svc.ensureForAssignment(INPUT);
expect(personaId).toBe("hostess-1");
expect(tavus.createObjectives).toHaveBeenCalledTimes(1);
// every objective got the callback URL stamped
for (const o of tavus.createObjectives.mock.calls[0][0]) {
expect(o.callback_url).toContain("/tavus/webhooks/function-call");
}
expect(tavus.createGuardrails).toHaveBeenCalledTimes(ONBOARDING_GUARDRAILS.length);
expect(tavus.createTool).toHaveBeenCalledTimes(ONBOARDING_TOOL_DEFINITIONS.length);
expect(tavus.attachToolsToPal).toHaveBeenCalledWith(
"hostess-1",
expect.arrayContaining(["tool-1"]),
"org-key",
);
const personaArgs = tavus.createPersona.mock.calls[0][0];
expect(personaArgs.objectivesId).toBe("obj-set-1");
expect(personaArgs.guardrailTags).toEqual(["hwork-baseline", "hwork-onboarding"]);
expect(personaArgs.defaultReplicaId).toBe("r-1");
expect(personaArgs.byoLlm).toBeUndefined();
expect(personaArgs.llm).toBeUndefined(); // Tavus-hosted LLM โ objectives need it
expect(personaArgs.systemPrompt).toContain("Linnea");
// OSA stamped with id + hash
expect(osaRepo.update).toHaveBeenCalledWith(
{ id: "assign-1", orgId: "org-1" },
expect.objectContaining({ tavusOnboardingPersonaId: "hostess-1" }),
);
});
it("reuses the existing PAL when the content hash matches", async () => {
const { svc, tavus } = makeService();
const hash = onboardingHostessContentHash({
systemPrompt: buildHostessSystemPrompt({ specialistName: "Linnea", specialistTitle: "GTM Specialist" }),
replicaId: "r-1",
});
const personaId = await svc.ensureForAssignment({
...INPUT,
assignment: { ...ASSIGNMENT, tavusOnboardingPersonaId: "hostess-old", tavusOnboardingPersonaHash: hash },
});
expect(personaId).toBe("hostess-old");
expect(tavus.createPersona).not.toHaveBeenCalled();
});
it("re-provisions and archives the stale PAL when the hash changed", async () => {
const { svc, tavus } = makeService();
const personaId = await svc.ensureForAssignment({
...INPUT,
assignment: { ...ASSIGNMENT, tavusOnboardingPersonaId: "hostess-old", tavusOnboardingPersonaHash: "stale" },
});
expect(personaId).toBe("hostess-1");
expect(tavus.archivePersona).toHaveBeenCalledWith("hostess-old", "org-key");
});
it("dedupes account assets by name instead of re-creating", async () => {
const { svc, tavus } = makeService({
tavus: {
listObjectives: jest
.fn()
.mockResolvedValue([{ objectives_id: "obj-existing", objective_name: "hwonb_consent_to_proceed" }]),
listGuardrails: jest.fn().mockResolvedValue(
ONBOARDING_GUARDRAILS.map((g, i) => ({ uuid: `g-${i}`, guardrail_name: g.guardrail_name })),
),
listTools: jest.fn().mockResolvedValue(
ONBOARDING_TOOL_DEFINITIONS.map((t, i) => ({ tool_id: `t-${i}`, name: t.name })),
),
},
});
await svc.ensureForAssignment(INPUT);
expect(tavus.createObjectives).not.toHaveBeenCalled();
expect(tavus.createGuardrails).not.toHaveBeenCalled();
expect(tavus.createTool).not.toHaveBeenCalled();
expect(tavus.createPersona.mock.calls[0][0].objectivesId).toBe("obj-existing");
});
});
- Step 2: Run to verify failure
Run: cd api && npx jest src/meetings/onboarding-hostess-persona.service.spec.ts 2>&1 | tail -5
Expected: FAIL โ module not found.
- Step 3: Implement the service
Create api/src/meetings/onboarding-hostess-persona.service.ts:
import { Injectable, Logger, Optional, ServiceUnavailableException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { OrgSpecialistAssignment } from "../onboarding/onboarding.entities";
import { TavusClient } from "../tavus/tavus.client";
import { TavusControlPlaneCredentialService } from "../tavus/tavus-control-plane-credential.service";
import { buildTavusCallbackUrl } from "../tavus/tavus-callback-url.util";
import {
ONBOARDING_GUARDRAILS,
ONBOARDING_GUARDRAIL_TAG,
ONBOARDING_OBJECTIVES,
ONBOARDING_TOOL_DEFINITIONS,
buildHostessSystemPrompt,
onboardingHostessContentHash,
withObjectiveCallbacks,
} from "./onboarding-intake.definitions";
interface AccountAssets {
objectivesId: string;
toolIds: string[];
}
/**
* #5966 โ provisions the per-assignment "onboarding hostess" PAL.
*
* The hostess PAL deliberately uses a TAVUS-HOSTED LLM (no byoLlm/llm layer):
* Tavus Objectives steer the conversation by injecting objective state into
* the LLM's message array, and both of our custom-LLM bridges forward only
* the final user utterance โ so objectives can never steer a custom-LLM
* persona. The hostess is a hostess, not a worker (issue #5966): she needs no
* Hermes, no AgentFS home, no day-job soul. Everything she captures lands in
* `client_briefs.data` via the native function tools + objective callbacks.
*
* Account-level assets (objectives set, guardrails, function tools) are
* ensured once per (process, org credential) and deduped by name against the
* live account, so re-boots never grow the registry. The per-OSA PAL is
* hash-gated: a change to prompt/objectives/guardrails/tools/replica archives
* the stale PAL and provisions a fresh one on next onboarding start.
*/
@Injectable()
export class OnboardingHostessPersonaService {
private readonly logger = new Logger(OnboardingHostessPersonaService.name);
/** Memoized account assets per credential (key: apiKey or "default"). */
private readonly accountAssets = new Map<string, Promise<AccountAssets>>();
constructor(
private readonly tavus: TavusClient,
@InjectRepository(OrgSpecialistAssignment)
private readonly osaRepo: Repository<OrgSpecialistAssignment>,
@Optional()
private readonly tavusCredentials?: TavusControlPlaneCredentialService,
) {}
async ensureForAssignment(input: {
assignment: OrgSpecialistAssignment;
specialistName: string | null;
specialistTitle: string | null;
replicaId: string;
callbackBaseUrl: string;
}): Promise<string> {
const systemPrompt = buildHostessSystemPrompt({
specialistName: input.specialistName,
specialistTitle: input.specialistTitle,
});
const hash = onboardingHostessContentHash({ systemPrompt, replicaId: input.replicaId });
if (
input.assignment.tavusOnboardingPersonaId
&& input.assignment.tavusOnboardingPersonaHash === hash
) {
return input.assignment.tavusOnboardingPersonaId;
}
const credential = await this.tavusCredentials?.resolveForOrg(input.assignment.orgId);
const apiKey = credential?.apiKey;
const assets = await this.ensureAccountAssets(apiKey, input.callbackBaseUrl);
const created = await this.tavus.createPersona({
name: `Onboarding hostess โ ${input.specialistName ?? "specialist"} (${input.assignment.orgId})`,
systemPrompt,
defaultReplicaId: input.replicaId,
objectivesId: assets.objectivesId,
// Baseline platform safety + the onboarding pacing set, via dynamic
// tag matching (same mechanism as scripts/provision-tavus-personas.ts).
guardrailTags: ["hwork-baseline", ONBOARDING_GUARDRAIL_TAG],
apiKey,
// NO byoLlm / llm layer: Tavus-hosted LLM is required for objectives.
});
if (!created?.persona_id) {
throw new ServiceUnavailableException("Tavus returned no persona_id for the onboarding hostess PAL");
}
await this.tavus.attachToolsToPal(created.persona_id, assets.toolIds, apiKey);
const stale = input.assignment.tavusOnboardingPersonaId;
await this.osaRepo.update(
{ id: input.assignment.id, orgId: input.assignment.orgId },
{ tavusOnboardingPersonaId: created.persona_id, tavusOnboardingPersonaHash: hash },
);
if (stale) {
void this.tavus.archivePersona(stale, apiKey).catch(() => undefined);
}
// Keep the in-memory row coherent for same-request reuse.
input.assignment.tavusOnboardingPersonaId = created.persona_id;
input.assignment.tavusOnboardingPersonaHash = hash;
return created.persona_id;
}
private ensureAccountAssets(
apiKey: string | undefined,
callbackBaseUrl: string,
): Promise<AccountAssets> {
const key = apiKey ?? "default";
const cached = this.accountAssets.get(key);
if (cached) return cached;
const pending = this.resolveAccountAssets(apiKey, callbackBaseUrl).catch((err) => {
this.accountAssets.delete(key); // don't cache failures
throw err;
});
this.accountAssets.set(key, pending);
return pending;
}
private async resolveAccountAssets(
apiKey: string | undefined,
callbackBaseUrl: string,
): Promise<AccountAssets> {
// Objectives: find the set by our entry objective's name, else create.
const existingObjectives = await this.tavus.listObjectives(apiKey);
const entry = existingObjectives.find(
(o) => o.objective_name === ONBOARDING_OBJECTIVES[0].objective_name,
);
const objectivesId = entry
? entry.objectives_id
: (
await this.tavus.createObjectives(
withObjectiveCallbacks(
buildTavusCallbackUrl(callbackBaseUrl, "/tavus/webhooks/function-call"),
),
apiKey,
)
).objectives_id;
// Guardrails: create the missing ones (attachment is via tags, so only
// existence matters here).
const existingGuardrails = await this.tavus.listGuardrails(apiKey);
const guardrailNames = new Set(existingGuardrails.map((g) => g.guardrail_name));
for (const g of ONBOARDING_GUARDRAILS) {
if (!guardrailNames.has(g.guardrail_name)) {
await this.tavus.createGuardrails(g, apiKey);
}
}
// Tools: dedupe by name against the account registry (same pattern the
// PAL-sync flow uses); collect ids for attachment.
const existingTools = await this.tavus.listTools(apiKey);
const byName = new Map(existingTools.map((t) => [t.name, t.tool_id]));
const toolIds: string[] = [];
for (const def of ONBOARDING_TOOL_DEFINITIONS) {
const existing = byName.get(def.name);
if (existing) {
toolIds.push(existing);
} else {
const created = await this.tavus.createTool(
{ name: def.name, description: def.description, parameters: def.parameters },
apiKey,
);
toolIds.push(created.tool_id);
}
}
this.logger.log(
`[onboarding-hostess] account assets ready: objectives=${objectivesId} tools=${toolIds.length}`,
);
return { objectivesId, toolIds };
}
}
Note: createTool today takes (def); Task 1 added the optional apiKey. If the account-credential service is absent (lean test modules), apiKey is undefined and the client falls back to its env-configured key โ same behavior as every other flow.
Register in api/src/meetings/meetings.module.ts: add OnboardingHostessPersonaService to providers (and exports if other modules need it later โ not required now).
- Step 4: Run to verify pass
Run: cd api && npx jest src/meetings/onboarding-hostess-persona.service.spec.ts 2>&1 | tail -5
Expected: PASS.
- Step 5: Commit
git add api/src/meetings/onboarding-hostess-persona.service.ts api/src/meetings/onboarding-hostess-persona.service.spec.ts api/src/meetings/meetings.module.ts
git commit -m "feat(meetings): lazily provision the per-assignment onboarding hostess PAL (#5966)"
Task 5: ConversationStarterService โ lean context, direct hostess conversation startโ
Files:
-
Modify:
api/src/meetings/conversation-starter.service.ts -
Modify:
api/src/meetings/conversation-starter.service.onboarding-persona-context.spec.ts -
Delete:
api/src/meetings/conversation-starter.service.onboarding-directive.spec.ts(tests the deleted role-override/intake-script; salvage nothing โ its concerns now live in objectives/guardrails) -
Check:
api/src/meetings/conversation-starter.service.recording.spec.tsand.active-directives.spec.tsโ update onboarding-path assertions (see Step 3 notes) -
Step 1: Make the edits
In api/src/meetings/conversation-starter.service.ts:
ONBOARDING_DEFAULTSโ replace the block:
/**
* Onboarding-specific defaults (#1481, re-scoped by #5966). The intake is a
* client meeting, not a new-employee orientation: ~13 minutes of objectives
* (see onboarding-intake.definitions.ts) + headroom. Was 2400s when the call
* carried the 9-bucket orientation script.
*/
const ONBOARDING_DEFAULTS = {
maxCallDurationSeconds: 900, // 15 min โ 13-minute objective flow + headroom
participantLeftTimeoutSeconds: 60,
participantAbsentTimeoutSeconds: 300,
};
-
Delete
ONBOARDING_INTAKE_SCRIPT(lines ~135โ239) andbuildOnboardingRoleOverride(lines ~241โ274) entirely. The flow now lives in Tavus objectives; the constraints in guardrails; the personality in the hostess PAL's system prompt. -
Replace
buildOnboardingPersonaContextwith a lean per-call block (identity/objective/outcome moved to the hostess PAL prompt + objectives โconversational_contextcarries per-call data ONLY, per the issue's AC):
/**
* #5966 โ per-call client profile for an onboarding call. The hostess PAL's
* system prompt owns identity/personality and the attached objectives own the
* flow, so this block carries ONLY the per-call data the persona cannot know:
* who the client is. The company-confirmation objective reads this block to
* confirm-not-elicit. Empty fields are omitted, never rendered as blanks.
* (The stale reference to `EnvironmentProfile.data` / `flag_environment_change`
* is gone: capture lands in `client_briefs.data` via the handlers in
* api/src/client-briefs/handlers/onboarding-handlers.ts.)
*/
function buildOnboardingClientProfile(opts: {
clientOrgName?: string | null;
callerDisplayName?: string | null;
background: OnboardingClientBackground;
}): string {
const company = opts.clientOrgName?.trim();
const contact = opts.callerDisplayName?.trim();
const industry = opts.background.clientIndustry?.trim();
const timezone = opts.background.clientTimezone?.trim();
const validTimezone = timezone && TIMEZONES.includes(timezone) ? timezone : undefined;
const domains = (opts.background.clientDomains ?? [])
.map((d) => d?.trim())
.filter((d): d is string => !!d);
const goal = opts.background.clientStatedGoal?.trim();
const lines: string[] = [];
lines.push(` - Company: ${company || "(not provided)"}`);
if (contact) lines.push(` - Primary contact: ${contact}`);
if (industry) lines.push(` - Domain / industry: ${industry}`);
if (validTimezone) lines.push(` - Timezone: ${validTimezone}`);
if (domains.length) lines.push(` - Web/email domains: ${domains.join(", ")}`);
if (goal) lines.push(` - Stated goal (from onboarding form): ${goal}`);
return `client_profile: |\n${lines.join("\n")}`;
}
-
buildOnboardingOpeningScriptโ two copy changes for the 13-minute shape (keep AIM โ SELF-INTRO โ PURPOSE, statement-only):"Over the next ten minutes or so"โ"Over the next ten to fifteen minutes".- Doc comment: note the consent objective (
hwonb_consent_to_proceed) now owns the first-turn consent confirmation formerly held by intake bucket 1.
-
startOnboardingConversationโ replace the body's context assembly and provider start:
async startOnboardingConversation(
opts: {
assignmentId: string;
personaId: string; // #5966: the onboarding hostess PAL (see OnboardingHostessPersonaService)
replicaId: string;
callbackBaseUrl: string;
orgId?: string;
callerPersonId?: string;
sourceId?: string;
} & ConversationIdentityContext &
OnboardingClientBackground &
ConversationStarterOverrides,
): Promise<{ conversationId: string; conversationUrl: string }> {
const callbackUrl = buildTavusCallbackUrl(
opts.callbackBaseUrl,
"/tavus/webhooks/function-call",
);
const bodyLines: string[] = [];
let specialistName: string | null = null;
let assignmentOrgId: string | null = null;
let assignmentSpecialistId: string | null = null;
const assignment = await this.assignmentRepo.findOne({
where: { id: opts.assignmentId },
});
if (assignment) {
assignmentOrgId = assignment.orgId;
assignmentSpecialistId = assignment.specialistId;
const specialist = await this.specialistRepo.findOne({
where: { id: assignment.specialistId },
});
specialistName = specialist?.name ?? specialist?.fullName ?? null;
if (specialist?.specialtyDomains?.length) {
bodyLines.push(
`specialist_domains: ${specialist.specialtyDomains.join(", ")}`,
);
}
}
// #5966 โ per-call data ONLY. Flow = objectives on the hostess PAL;
// constraints = guardrails; personality = the PAL system prompt.
bodyLines.push(
buildOnboardingClientProfile({
clientOrgName: opts.clientOrgName,
callerDisplayName: opts.callerDisplayName,
background: {
clientIndustry: opts.clientIndustry,
clientDomains: opts.clientDomains,
clientStatedGoal: opts.clientStatedGoal,
clientTimezone: opts.clientTimezone,
},
}),
);
// #4425 / #4429 โ Active Directives (red lines) + shared memory still
// bind the intake call; no CALL_MODE role-override segment anymore (the
// hostess PAL's own system prompt owns call mode now). Unlike the managed
// Hermes flow, this preamble now actually reaches the model: the hostess
// PAL is Tavus-hosted, so `conversational_context` is sent verbatim.
const preamble = assignmentOrgId && assignmentSpecialistId
? await this.contextBuilder.buildVideoPreamble({
orgId: assignmentOrgId,
specialistId: assignmentSpecialistId,
specialistAssignmentId: opts.assignmentId,
})
: null;
const conversationalContext = this.buildContext({
preamble: preamble ?? undefined,
identity: this.identityFrom(opts),
bodyLines,
});
this.assertContextWithinBudget(conversationalContext, opts.assignmentId);
const properties = this.propertiesFor("onboarding", opts);
const customGreeting =
opts.customGreeting ??
buildOnboardingOpeningScript({
specialistName,
clientOrgName: opts.clientOrgName,
callerDisplayName: opts.callerDisplayName,
});
// #5966 โ the hostess PAL runs a Tavus-hosted LLM (objectives cannot
// steer a custom-LLM persona through our bridges), so this path does NOT
// use the managed runtime (no release materialization, no runtime-binding
// marker): the context above is sent to Tavus as-is and native function
// calls flow back through /tavus/webhooks/function-call.
const orgId = opts.orgId ?? assignmentOrgId ?? "";
try {
const credential = orgId
? await this.tavusCredentials?.resolveForOrg(orgId)
: undefined;
const conversation = await this.tavus.createConversation({
personaId: opts.personaId,
conversationName: `Onboarding for assignment ${opts.assignmentId}`,
callbackUrl,
conversationalContext,
replicaId: opts.replicaId,
properties,
customGreeting,
apiKey: credential?.apiKey,
});
return {
conversationId: conversation.conversation_id,
conversationUrl: conversation.conversation_url,
};
} catch (err) {
if (err instanceof TavusClientError) throw this.toHttpException(err);
throw err;
}
}
- Update the class doc comment's onboarding paragraph: onboarding now runs on the hostess PAL (objectives/guardrails/prompt โ #5966); reporting stays managed. Keep
ONBOARDING_CONTEXT_TOKEN_BUDGETand its guard (the context is far smaller now; the guard still catches a future regression).
- Step 2: Update the spec files
conversation-starter.service.onboarding-directive.spec.ts: delete the file.
conversation-starter.service.onboarding-persona-context.spec.ts: the context now goes to tavus.createConversation directly (no runtimeBindings.issue for onboarding). Update the helper commentary and assertions; core new tests:
it("sends the real conversational_context to Tavus (no binding marker)", async () => {
const { svc, tavus } = makeService();
await svc.startOnboardingConversation(OPTS);
const ctx: string = tavus.createConversation.mock.calls[0][0].conversationalContext;
expect(ctx).not.toContain("humanwork_runtime_binding:");
expect(ctx).toContain("client_profile:");
expect(ctx).toContain("Company: Acme Corp");
expect(ctx).toContain("Automotive retail");
expect(ctx).toContain("Grow online lead volume by 30%");
});
it("carries per-call data only โ no intake script, no role override, no identity section", async () => {
const { svc, tavus } = makeService();
await svc.startOnboardingConversation(OPTS);
const ctx: string = tavus.createConversation.mock.calls[0][0].conversationalContext;
expect(ctx).not.toContain("onboarding_intake_script");
expect(ctx).not.toContain("onboarding_call_directive");
expect(ctx).not.toContain("AGENT IDENTITY & ROLE");
});
it("uses the hostess PAL id and the org credential, and never issues a runtime binding", async () => {
const { svc, tavus, issue } = makeService();
await svc.startOnboardingConversation(OPTS);
expect(issue).not.toHaveBeenCalled();
const call = tavus.createConversation.mock.calls[0][0];
expect(call.personaId).toBe(OPTS.personaId);
expect(call.apiKey).toBe("org-tavus-key");
});
it("caps onboarding calls at 15 minutes", async () => {
const { svc, tavus } = makeService();
await svc.startOnboardingConversation(OPTS);
expect(tavus.createConversation.mock.calls[0][0].properties.max_call_duration).toBe(900);
});
it("keeps the scripted statement-only opening with the 10-15 minute framing", async () => {
const { svc, tavus } = makeService();
await svc.startOnboardingConversation(OPTS);
const greeting: string = tavus.createConversation.mock.calls[0][0].customGreeting;
expect(greeting).toContain("ten to fifteen minutes");
expect(greeting).toContain("recording");
expect(greeting.trim().endsWith("?")).toBe(false);
});
Keep passing tests that assert greeting structure / omitted-empty-fields behavior, re-pointing their reads from issue.mock.calls[0][0].contextEvidence to tavus.createConversation.mock.calls[0][0].conversationalContext.
conversation-starter.service.recording.spec.ts: onboarding recording assertions must now read the tavus.createConversation call (recording props unchanged: enable_recording/enable_transcription still true). Reporting-path tests unchanged.
conversation-starter.service.active-directives.spec.ts: onboarding-path directive tests now assert the preamble text appears in conversationalContext sent to Tavus and that no CALL_MODE extra segment is passed to buildVideoPreamble (extraSegments absent). Reporting-path tests unchanged.
- Step 3: Run the meetings suite
Run: cd api && npx jest src/meetings/ 2>&1 | tail -15
Expected: PASS (the meetings.service.* specs still pass because Task 6 hasn't changed callers yet โ if meetings.service.onboarding-verify.spec.ts stubs startOnboardingConversation it is unaffected).
- Step 4: Commit
git add api/src/meetings/conversation-starter.service.ts api/src/meetings/conversation-starter.service.*.spec.ts
git rm api/src/meetings/conversation-starter.service.onboarding-directive.spec.ts
git commit -m "feat(meetings): onboarding calls run direct on the hostess PAL with lean per-call context (#5966)"
Task 6: MeetingsService โ resolve the hostess PAL on start + refreshโ
Files:
-
Modify:
api/src/meetings/meetings.service.ts(constructor + the two onboarding persona resolutions at ~lines 404 and 449) -
Test:
api/src/meetings/meetings.service.onboarding-verify.spec.ts(extend) -
Step 1: Make the edits
Inject the new service (constructor): private readonly hostessPersonas: OnboardingHostessPersonaService, with import from ./onboarding-hostess-persona.service.
At both onboarding persona resolutions (~line 404 refresh path, ~line 449 create path), replace:
const personaId = specialist.tavusPersonaId;
if (!personaId) {
throw new ConflictException({
code: "persona_not_provisioned",
...
});
}
const replicaId = this.resolveReplicaId(assignment, specialist);
if (!replicaId) { ... }
with (resolve replica FIRST โ the hostess PAL needs it as default_replica_id):
const replicaId = this.resolveReplicaId(assignment, specialist);
if (!replicaId) {
throw new ConflictException({
code: "replica_not_set",
message: this.replicaNotSetMessage(specialist, assignment),
});
}
// #5966 โ onboarding runs against the dedicated hostess PAL (objectives +
// guardrails + native tools on a Tavus-hosted LLM), provisioned lazily per
// assignment. The catalog persona (specialist.tavusPersonaId) is no longer
// used for onboarding; reporting still resolves its own persona.
const personaId = await this.hostessPersonas.ensureForAssignment({
assignment,
specialistName: specialist.name ?? specialist.fullName ?? null,
specialistTitle: specialist.title ?? null,
replicaId,
callbackBaseUrl: opts.callbackBaseUrl,
});
(Adjust local variable names on the refresh path: personaIdForRefresh / replicaIdForRefresh keep their names, same substitution. Delete the now-unused persona_not_provisioned throw on these two paths only โ reporting keeps its own.)
- Step 2: Extend the onboarding-verify spec
In meetings.service.onboarding-verify.spec.ts, stub hostessPersonas on the service under test (svc.hostessPersonas = { ensureForAssignment: jest.fn().mockResolvedValue("hostess-1") }) wherever the existing specs stub collaborators, and add:
it("provisions the hostess PAL and passes it to the conversation starter (#5966)", async () => {
// arrange per the file's existing startOnboarding fixture
await svc.startOnboarding(startOpts);
expect(svc.hostessPersonas.ensureForAssignment).toHaveBeenCalledWith(
expect.objectContaining({ replicaId: expect.any(String), callbackBaseUrl: startOpts.callbackBaseUrl }),
);
expect(conversationStarter.startOnboardingConversation).toHaveBeenCalledWith(
expect.objectContaining({ personaId: "hostess-1" }),
);
});
Any existing test asserting the persona_not_provisioned conflict for onboarding: delete it (the hostess PAL is provisioned on demand; the conflict remains only for reporting).
- Step 3: Run the suite
Run: cd api && npx jest src/meetings/meetings.service 2>&1 | tail -10
Expected: PASS.
- Step 4: Commit
git add api/src/meetings/meetings.service.ts api/src/meetings/meetings.service.onboarding-verify.spec.ts
git commit -m "feat(meetings): onboarding starts resolve the hostess PAL instead of the catalog persona (#5966)"
Task 7: Webhook controller โ scoped native tool-call dispatch (onboarding only)โ
Files:
-
Modify:
api/src/tavus/tavus.controller.ts -
Test:
api/src/tavus/tavus.controller.onboarding-tool-call.spec.ts(create; copy harness fromtavus.controller.video-call-lifecycle.spec.ts) -
Step 1: Write the failing tests
describe("onboarding native tool-call dispatch (#5966)", () => {
it("dispatches a conversation.tool_call for an onboarding meeting to the router", async () => {
meetingRepo.findOne.mockResolvedValue({ id: "m-1", orgId: "org-1", kind: "onboarding" });
router.dispatch.mockResolvedValue(true);
const res = await controller.functionCallWebhook(req, {
event_type: "conversation.tool_call",
conversation_id: "conv-1",
tool_name: "record_environment_fact",
tool_arguments: JSON.stringify({ category: "role", key: "first_task", value: "weekly GTM report" }),
} as any);
expect(res).toEqual({ status: "ok", dispatched: "record_environment_fact" });
expect(router.dispatch).toHaveBeenCalledWith(
"record_environment_fact",
expect.objectContaining({ conversationId: "conv-1", meetingId: "m-1", orgId: "org-1" }),
expect.objectContaining({ key: "first_task" }),
);
});
it("keeps ignoring tool calls for non-onboarding conversations (Hermes authoritative)", async () => {
meetingRepo.findOne.mockResolvedValue(null); // not a meeting (e.g. a video_call)
const res = await controller.functionCallWebhook(req, {
event_type: "conversation.tool_call",
conversation_id: "conv-2",
tool_name: "record_environment_fact",
tool_arguments: {},
} as any);
expect(res.status).toBe("ignored_native_tools_disabled");
expect(router.dispatch).not.toHaveBeenCalled();
});
it("accepts legacy function_name payloads for onboarding meetings", async () => {
meetingRepo.findOne.mockResolvedValue({ id: "m-1", orgId: "org-1", kind: "onboarding" });
router.dispatch.mockResolvedValue(true);
const res = await controller.functionCallWebhook(req, {
conversation_id: "conv-1",
function_name: "record_guardrail",
arguments: { kind: "compliance", rule: "no pricing talk" },
} as any);
expect(res).toEqual({ status: "ok", dispatched: "record_guardrail" });
});
it("acks unknown function names without dispatch (no Tavus retry storm)", async () => {
meetingRepo.findOne.mockResolvedValue({ id: "m-1", orgId: "org-1", kind: "onboarding" });
router.dispatch.mockResolvedValue(false);
const res = await controller.functionCallWebhook(req, {
event_type: "conversation.tool_call",
conversation_id: "conv-1",
tool_name: "not_a_real_function",
tool_arguments: {},
} as any);
expect(res.status).toBe("unknown_function");
});
it("dedupes by idempotency key", async () => {
meetingRepo.findOne.mockResolvedValue({ id: "m-1", orgId: "org-1", kind: "onboarding" });
router.reserveIdempotency.mockResolvedValueOnce(false);
const res = await controller.functionCallWebhook(req, {
event_type: "conversation.tool_call",
conversation_id: "conv-1",
idempotency_key: "idem-1",
tool_name: "record_environment_fact",
tool_arguments: {},
} as any);
expect(res.status).toBe("duplicate");
expect(router.dispatch).not.toHaveBeenCalled();
});
});
- Step 2: Run to verify failure
Run: cd api && npx jest src/tavus/tavus.controller.onboarding-tool-call.spec.ts 2>&1 | tail -5
Expected: FAIL.
- Step 3: Implement the dispatch
In tavus.controller.ts, replace the conversation.tool_call branch (~line 141) and the trailing legacy branch (~line 152):
if (eventType === "conversation.tool_call") {
return this.onboardingToolCallWebhook(req, body as unknown as TavusToolCallEvent);
}
// Legacy deliveries carry function_name at the top level with no
// event_type. #5966 routes them through the same onboarding-scoped
// dispatch; every non-onboarding native call stays ignored (Hermes is
// the tool authority everywhere else).
if ((body as Record<string, unknown>).function_name) {
return this.onboardingToolCallWebhook(req, body as unknown as TavusToolCallEvent);
}
this.logger.warn(
"[Tavus] Ignoring provider-native function call without a name; Hermes tool gateway is authoritative",
);
return { status: "ignored_native_tools_disabled" };
Add the private handler (near conversationStartedWebhook):
/**
* #5966 โ native tool-call dispatch, ONBOARDING MEETINGS ONLY.
*
* The onboarding hostess PAL runs a Tavus-hosted LLM with the record_*
* function tools attached, so its tool calls arrive here and land in
* `client_briefs.data` via the handlers registered by OnboardingHandlers.
* Every other conversation kind keeps the runtime-v2 rule: Hermes is the
* only tool-use authority, native tool calls are ignored.
*/
private async onboardingToolCallWebhook(req: Request, body: TavusToolCallEvent) {
const conversationId = body.conversation_id;
const functionName = body.tool_name ?? body.function_name;
if (!conversationId || !functionName) {
throw new BadRequestException("conversation_id and tool name are required");
}
const meeting = await this.rls.withInternalContext(async (manager) =>
manager.getRepository(Meeting).findOne({
where: { tavusConversationId: conversationId },
}),
);
if (!meeting || meeting.kind !== "onboarding") {
this.logger.warn(
`[Tavus] Ignoring native tool call "${functionName}" for conversation ${conversationId} โ ` +
`not an onboarding meeting; Hermes tool gateway is authoritative`,
);
return { status: "ignored_native_tools_disabled" };
}
const idempotencyKey = this.extractIdempotencyKey(req, body);
if (!(await this.router.reserveIdempotency(idempotencyKey))) {
return { status: "duplicate" };
}
const rawArgs = body.tool_arguments ?? body.arguments;
let args: Record<string, unknown> = {};
if (typeof rawArgs === "string") {
try {
args = JSON.parse(rawArgs) as Record<string, unknown>;
} catch {
this.logger.warn(`[Tavus] Unparseable tool_arguments for "${functionName}" โ dispatching {}`);
}
} else if (rawArgs && typeof rawArgs === "object") {
args = rawArgs as Record<string, unknown>;
}
const dispatched = await this.router.dispatch(functionName, {
conversationId,
idempotencyKey: idempotencyKey ?? "",
orgId: meeting.orgId,
meetingId: meeting.id,
}, args);
if (!dispatched) {
// Ack rather than 4xx: Tavus retries non-2xx and the name will never
// start resolving โ log loudly instead.
this.logger.warn(`[Tavus] No handler registered for onboarding tool call "${functionName}"`);
return { status: "unknown_function", function: functionName };
}
return { status: "ok", dispatched: functionName };
}
(Meeting repository access: the controller already imports Meeting-adjacent entities for the ended/transcript flows โ follow whatever repo-resolution pattern those private handlers use, manager.getRepository(Meeting) under rls.withInternalContext matches conversationStartedWebhook.)
- Step 4: Run to verify pass
Run: cd api && npx jest src/tavus/tavus.controller 2>&1 | tail -10
Expected: PASS โ including the pre-existing lifecycle/transcript specs.
- Step 5: Commit
git add api/src/tavus/tavus.controller.ts api/src/tavus/tavus.controller.onboarding-tool-call.spec.ts
git commit -m "feat(tavus): dispatch native tool calls for onboarding meetings to the brief handlers (#5966)"
Task 8: Objective-completion callbacks โ brief progressโ
Files:
-
Modify:
api/src/client-briefs/handlers/onboarding-handlers.ts(new handler + registration) -
Modify:
api/src/tavus/tavus.controller.ts(route objective payloads) -
Test:
api/src/client-briefs/handlers/onboarding-handlers.spec.ts(extend if present; create otherwise) and extendtavus.controller.onboarding-tool-call.spec.ts -
Step 1: Write the failing tests
Controller routing (append to the Task 7 spec file):
it("routes objective-completion callbacks to the objective handler", async () => {
meetingRepo.findOne.mockResolvedValue({ id: "m-1", orgId: "org-1", kind: "onboarding" });
router.dispatch.mockResolvedValue(true);
const res = await controller.functionCallWebhook(req, {
event_type: "conversation.objective.completed",
conversation_id: "conv-1",
objective_name: "hwonb_role_and_first_task",
output_variables: { first_task: "weekly GTM report" },
} as any);
expect(res).toEqual({ status: "ok", dispatched: "__objective_completed__" });
expect(router.dispatch).toHaveBeenCalledWith(
"__objective_completed__",
expect.objectContaining({ meetingId: "m-1" }),
expect.objectContaining({
objective_name: "hwonb_role_and_first_task",
output_variables: { first_task: "weekly GTM report" },
}),
);
});
Handler (new/extended spec, mirroring the stub style of the other handler tests โ stub briefs + rls):
it("persists objective output variables into the facts bucket (#5966)", async () => {
await handlers.objectiveCompleted(ctx, {
objective_name: "hwonb_role_and_first_task",
output_variables: { first_task: "weekly GTM report", reports_to: "Minh" },
});
expect(briefs.appendToBucket).toHaveBeenCalledWith(
"brief-1",
"org-1",
CLIENT_BRIEF_BUCKETS.FACTS,
expect.objectContaining({
category: "objective_completion",
key: "hwonb_role_and_first_task",
value: expect.stringContaining("weekly GTM report"),
}),
null,
);
});
- Step 2: Run to verify failure
Run: cd api && npx jest src/client-briefs src/tavus/tavus.controller.onboarding-tool-call 2>&1 | tail -5
Expected: FAIL.
- Step 3: Implement
onboarding-handlers.ts โ register (in registerAll): this.router.register("__objective_completed__", this.objectiveCompleted); and add:
/**
* #5966 โ objective-completion callback (Tavus posts conversation_id,
* objective name and collected output_variables when an objective
* completes). Belt-and-braces alongside the record_* function calls: even
* if the model under-calls the explicit tools, the objectives pipeline
* lands its captured variables in the brief. Redundant entries are fine โ
* the buckets are append-only journals feeding summary + SOUL generation.
*/
objectiveCompleted = async (
ctx: TavusWebhookHandlerContext,
args: Record<string, unknown>,
): Promise<void> => {
const objectiveName = typeof args.objective_name === "string" ? args.objective_name : "unknown";
const outputs = (args.output_variables ?? {}) as Record<string, unknown>;
const assignmentId = await this.resolveAssignmentId(ctx);
const brief = await this.briefs.getByAssignment(assignmentId);
await this.briefs.appendToBucket(
brief.id,
ctx.orgId,
CLIENT_BRIEF_BUCKETS.FACTS,
{
category: "objective_completion",
key: objectiveName,
value: JSON.stringify(outputs),
captured_at: new Date().toISOString(),
idempotency_key: ctx.idempotencyKey,
},
null,
);
};
Also fix the file-top doc comment's function-name table to add the new entry.
tavus.controller.ts โ in functionCallWebhook, before the unknown-event_type fallthrough, route on payload shape (event-type string unverified against the live API, so match the documented payload instead):
// #5966 โ objective-completion callbacks. Exact event_type naming is
// not contractual in the docs; the payload shape (objective_name) is.
if (
eventType.startsWith("conversation.objective")
|| typeof (body as Record<string, unknown>).objective_name === "string"
) {
return this.onboardingObjectiveWebhook(req, body as Record<string, unknown>);
}
and the private handler (reuses the same meeting-scoped guard as Task 7 โ factor the meeting lookup into a small private resolveOnboardingMeeting(conversationId) used by both):
private async onboardingObjectiveWebhook(req: Request, body: Record<string, unknown>) {
const conversationId = body.conversation_id as string | undefined;
if (!conversationId) throw new BadRequestException("conversation_id is required");
const meeting = await this.resolveOnboardingMeeting(conversationId);
if (!meeting) return { status: "ignored_not_onboarding" };
const idempotencyKey = this.extractIdempotencyKey(req, body);
if (!(await this.router.reserveIdempotency(idempotencyKey))) {
return { status: "duplicate" };
}
await this.router.dispatch("__objective_completed__", {
conversationId,
idempotencyKey: idempotencyKey ?? "",
orgId: meeting.orgId,
meetingId: meeting.id,
}, {
objective_name: body.objective_name,
output_variables: body.output_variables ?? {},
});
return { status: "ok", dispatched: "__objective_completed__" };
}
- Step 4: Run to verify pass
Run: cd api && npx jest src/client-briefs src/tavus/tavus.controller 2>&1 | tail -10
Expected: PASS.
- Step 5: Commit
git add api/src/client-briefs/handlers/onboarding-handlers.ts api/src/tavus/tavus.controller.ts api/src/tavus/tavus.controller.onboarding-tool-call.spec.ts api/src/client-briefs/handlers/onboarding-handlers.spec.ts
git commit -m "feat(client-briefs): persist objective-completion output variables into the brief (#5966)"
Task 9: Full verificationโ
- Step 1: Full API test suite (NOT per-file โ controller/guard wiring regressions only surface in the full run; see repo memory)
Run (Node 22 via nvm is required โ better-sqlite3 won't build on Node 26):
source ~/.nvm/nvm.sh && nvm use 22 && cd api && npm test 2>&1 | tail -25
Expected: same pass/fail set as dev baseline (dev's unit lane has known pre-existing reds โ compare failures against a clean dev run if anything is red, and only fix what this branch introduced).
- Step 2: Lint + typecheck
cd api && npx tsc --noEmit && npx eslint src/meetings src/tavus src/client-briefs --max-warnings 0 2>&1 | tail -5
Expected: clean (match the project's lint invocation from api/package.json scripts if different).
- Step 3: Commit any straggler fixes (one commit per concern per the user's global commit-granularity preference).
Task 10: Live-API QA checklist (dev environment, before the Animoca demo)โ
Not automatable โ do this with a real Tavus key on dev:
- Start an onboarding call on a dev assignment; confirm in the Tavus dashboard that the hostess PAL exists with objectives + guardrails + 6 tools attached, and
org_specialist_assignments.tavus_onboarding_persona_idis stamped. - Verify the
objectives_idsemantics: ifPOST /v2/objectivesturns out to return per-objective ids rather than one set id, adjustcreateObjectivesto return the ENTRY objective's id (hwonb_consent_to_proceed) โ the persona attaches the entry point andnext_*chains the rest. - Verify the objective-callback
event_typestring; tighten the controller's shape-match if Tavus sends a stable name. - Run a full call: consent โ confirm โ role/first task โ access branch (name Slack; verify the Slack branch fires) โ documents โ stakeholders โ wrap. Confirm
client_briefs.datafills (facts/guardrails/stakeholders/follow_ups/summary) with no human prompting the avatar forward, the wrap never mentions an account manager, and status flips topending_approval. - Confirm recording/transcript webhooks still land (KB ingestion + durability unchanged).
- Timebox check: the call fits inside 15 minutes; the avatar compresses rather than truncates when the client is chatty.
- Iterate objective prompts in PAL Maker / Charlie (makers.tavus.io) if pacing feels off โ prompts live in
onboarding-intake.definitions.ts; a definitions change bumps the content hash and re-provisions the PAL on next start.
Out of scope (deliberately)โ
- The round1 interviewer (hwork-recruiter) migration โ it shares the shape of this wrapper (
createObjectives/createGuardrails+ definitions module), not code across repos. - Reporting meetings, video-calls flow, and the managed Hermes pipeline โ untouched.
- Objective-progress UI widgets (recruiter tickets #1654/#1655 pattern) โ later.
- Tavus persona audit workflow updates (
audit-tavus-personas.yml) โ the hostess PAL will show up there; if the audit flags unexpected personas, extend its allowlist in a follow-up.
Self-review notes (spec coverage)โ
- 9 buckets โ objectives with completion criteria: Task 2 (9 objectives โ 7 budget rows + 2 access branches; tone/worries dropped per Alex, recoverable via
flag_follow_up_question). โ - Pacing / one-question / no-repeat / no-re-greet โ guardrails: Task 2. โ
- Personality + follow-up cap โ persona system prompt: Task 2 (
buildHostessSystemPrompt), attached in Task 4. โ conversational_contextper-call data only (and actually delivered): Task 5. โ- Access / documents / first-task as gates the call cannot end without: Task 2 (objective chain passes through all three;
next_*routing verified by test). โ - Wrap drops the account-manager promise: Task 2 (tested) + greeting check Task 5. โ
- Real call captures into
client_briefs.dataunprompted: Tasks 4+7+8 wiring, Task 10 live QA. โ maxCallDurationSecondsmatches the shorter call: Task 5 (900s, tested). โ- Greeting re-checked for the shorter shape: Task 5 (10โ15 min framing, statement-only preserved). โ
- Stale
EnvironmentProfile.datacomment fixed: Task 5 (comment onbuildOnboardingClientProfile). โ - Objectives โ handler writes tied together: objective prompts name the exact record_* functions; completion callbacks land output_variables in the brief (Task 8). โ