Skip to main content

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:

  1. The intake script currently reaches no model at all. ConversationStarterService.startProviderConversation (managed path, mandatory outside Jest) sends Tavus only an opaque runtime-binding marker as conversational_context; the assembled script is stored encrypted as contextEvidence on tavus_runtime_bindings โ€” audit only, never read back.
  2. Both custom-LLM bridges discard everything except the final user utterance. TavusOpenAiBridgeService.parseRequest (managed /tavus/v1) and ByoLlmService (/byo-llm/:token/v1) forward only the last user message 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.
  3. Native function calls are unconditionally ignored at tavus.controller.ts (ignored_native_tools_disabled), so OnboardingHandlers never fire today. Re-enabling in-call native tools was flagged as "a product decision" โ€” issue #5966's acceptance criterion ("a real onboarding call captures โ€ฆ into client_briefs.data") is that product decision, scoped to onboarding meetings only.
  4. 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.maxCallDurationSeconds shrinks to match, greeting re-checked for the shorter shape.
  • Guardrail attachment reuses the proven tag mechanism (guardrail_tags), so createPersona only needs a new objectives_id passthrough.
  • 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/objectives returns one objectives_id for the whole set or one per objective (docs show a single id; persona attaches a single objectives_id).
  • The exact event_type string of objective-completion callbacks (docs promise conversation_id, objective name, output_variables in the payload; routing matches on payload shape, not only on event_type).

File structureโ€‹

FileResponsibility
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 new describe block)

  • 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 (after tavusPersonaContentHash, ~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 to providers; ensure TavusModule exports used services are already imported โ€” the module already wires ConversationStarterService, so TavusClient and TavusControlPlaneCredentialService are 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.ts and .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:

  1. 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,
};
  1. Delete ONBOARDING_INTAKE_SCRIPT (lines ~135โ€“239) and buildOnboardingRoleOverride (lines ~241โ€“274) entirely. The flow now lives in Tavus objectives; the constraints in guardrails; the personality in the hostess PAL's system prompt.

  2. Replace buildOnboardingPersonaContext with a lean per-call block (identity/objective/outcome moved to the hostess PAL prompt + objectives โ€” conversational_context carries 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")}`;
}
  1. 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.
  2. 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;
}
}
  1. 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_BUDGET and 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 from tavus.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 extend tavus.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_id is stamped.
  • Verify the objectives_id semantics: if POST /v2/objectives turns out to return per-objective ids rather than one set id, adjust createObjectives to return the ENTRY objective's id (hwonb_consent_to_proceed) โ€” the persona attaches the entry point and next_* chains the rest.
  • Verify the objective-callback event_type string; 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.data fills (facts/guardrails/stakeholders/follow_ups/summary) with no human prompting the avatar forward, the wrap never mentions an account manager, and status flips to pending_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_context per-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.data unprompted: Tasks 4+7+8 wiring, Task 10 live QA. โœ“
  • maxCallDurationSeconds matches 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.data comment fixed: Task 5 (comment on buildOnboardingClientProfile). โœ“
  • Objectives โ†” handler writes tied together: objective prompts name the exact record_* functions; completion callbacks land output_variables in the brief (Task 8). โœ“