Skip to content
KugelAudioDocs

Recipes for hosted agents

Recipes for common hosted-agent integrations. A call link for chats and emails, the event a call comes from, what you already know about a logged-in visitor, one widget per channel, a recording notice, a feedback question, your own API with a secret, and a test copy before a change goes live.

On this page

Each recipe puts together what the other hosted-agent pages describe, for one common integration. They are written so that your coding agent can follow them: ask it to apply one to your project.

A link cannot start a call by itself: a browser lets a page use the microphone only after the visitor clicks. So the link opens a page of yours with the widget on it, and one click starts the call.

  1. Make a page on a website your widget accepts calls from, for example https://example.com/call (see which sites can start calls).

  2. Put the widget on it inline, so that its button sits in the page. Start from your widget's code in the dashboard, which carries every attribute your widget needs, and add the inline position, the label and the title:

    html
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Talk to our assistant</title>
      </head>
      <body>
        <main>
          <h1>Talk to our assistant</h1>
          <p>Press the button and allow your microphone.</p>
          <div data-kugelaudio-widget></div>
        </main>
    
        <script src="https://cdn.kugelaudio.com/widget/v1/widget.js"
          data-agent="YOUR_WIDGET_ID"
          data-position="inline"
          data-size="lg"
          data-label="Start the call"
          data-title="Our assistant"
          async></script>
      </body>
    </html>
  3. Send the page's address in your chat or email.

data-label is the button's text and data-title the heading of the call panel that opens from it. The visitor's click on that button starts the call. A button of your own design can start it too, by calling the widget's start(); see Start a call from your own button.

Tell the agent which event a call comes from

Attendees of a webinar click a call link, and the agent should know which webinar they come from without asking. Name the event in the link, let the widget pass it on, and let the agent look it up.

  1. Make the call page from the call link recipe and add data-context-params="event" to its widget tag. The widget then copies the page address's event parameter into the call's context (see Tell the agent where a call comes from):

    html
    <script src="https://cdn.kugelaudio.com/widget/v1/widget.js"
      data-agent="YOUR_WIDGET_ID"
      data-position="inline"
      data-size="lg"
      data-label="Start the call"
      data-context-params="event"
      async></script>
  2. Send each event's attendees their own link, such as https://example.com/call?event=webinar-11-04 for one webinar and https://example.com/call?event=webinar-11-17 for the next. Keep email addresses, names and anything else personal out of the link: whoever has the link can read it, and the context is stored with the call. To tell attendees apart, add a token that means something only to your own systems, such as &t=8f3k2q, and name it too: data-context-params="event,t".

  3. In the agent, map the event ids of your links to your own words, and render the sentence for the one the call came from. useClient() reads the call's context; its event is the link's event:

    TSX
    import { Text, useClient } from "curb";
    
    // Your own words for the events your links name. Any other value renders nothing.
    const WEBINARS: Record<string, string> = {
      "webinar-11-04": "our webinar on getting started",
      "webinar-11-17": "our webinar on advanced setups",
    };
    
    export function WebinarContext() {
      const { event } = useClient();
      const webinar = WEBINARS[event ?? ""];
      return webinar ? (
        <Text>The caller comes from {webinar}. Mention it in your greeting and answer questions about it.</Text>
      ) : null;
    }

    Render <WebinarContext /> inside <Agent> in src/agent/agent.tsx, after <SystemPrompt>.

The agent knows the event from its first sentence. Anyone can edit a link, so the event is a hint and never a reason to unlock anything: a value your table does not know renders nothing, and the agent talks as it would without one.

Pass what you already know (a logged-in user)

A visitor who is logged in on your website should not have to spell their email address for the agent. Give the widget what your page already knows, and let the agent use it instead of asking.

  1. On the page, set the widget's context property once you know who is logged in, and set it to null when they log out:

    JavaScript
    customElements.whenDefined('kugelaudio-agent').then(() => {
      document.querySelector('kugelaudio-agent').context = { email: user.email, name: user.name }
    })

    The next call to start carries it (see Tell the agent where a call comes from). The property stays in the page's memory and is never written into its HTML, so a session recording, which captures the HTML, does not capture the address. For the same reason, keep personal details out of the tag's data-context, even on a page rendered on the server.

  2. In the agent, useClient() reads the call's context, each key without the client. prefix. Tell the model the address so it does not ask:

    TSX
    import { Instruction, useClient } from "curb";
    
    export function KnownCaller() {
      const { email } = useClient();
      // Text from the page: only something shaped like an address gets in.
      if (!email || !/^[^\s@"]+@[^\s@"]+$/.test(email)) return null;
      return <Instruction>{`The caller is logged in with the email address "${email}". Use it and do not ask for it.`}</Instruction>;
    }

    Render <KnownCaller /> inside <Agent> in src/agent/agent.tsx, after <SystemPrompt>. Without an address it renders nothing, and the agent works as before.

  3. Try it locally. --client gives the conversation the context a page would send:

    Bash
    npx curb say --new --client email=max@example.com "Hi"

    Continue with a request that needs the address: the agent uses max@example.com instead of asking for it.

The page sets the context, and anyone who controls the page can change it. Use it to prefill and to skip questions, never as proof of who the caller is or to unlock their account's data; to act on an account, check the caller in a tool against your own system. Send only what the agent needs: the context is stored with the call.

Compare channels with one widget per channel

Give each channel its own widget: one for the button on your website, one for the link in your emails, one for the link in your chats. Every call records the id of the widget it came through, so you can tell the channels apart.

  1. Create one widget per channel. The dashboard's Add to your website names a widget after its website, and an agent's widgets need different names, so let your coding agent create the second and later widgets for the same website: add_agent_website takes a name, such as Email link or Chat link.
  2. Put each widget's code only where its channel leads: the website widget on your site, the email widget on the page your emails link to (https://example.com/call/email), and so on.
  3. To see where a call came from, open it on the agent's Calls page and choose the IDs button. Endpoint is the id of the widget the call came through, the same id as in that widget's data-agent. list_agent_websites lists every widget's id with its name.

Show a recording notice before the call

The widget has no consent step: a click on its button starts the call. Put your notice on the page above the button, so that visitors read it before they click. No call starts and no microphone opens before that click.

Whether a call is recorded is decided when the call starts, by the agent's Settings > Recording and retention. A call that starts while the agent keeps nothing has no transcript, audio or activity afterwards. Write the notice to match what that section says.

html
<p>
  This call is recorded and transcribed so that we can help you. Our
  <a href="https://example.com/privacy">privacy policy</a> says how long we keep it.
</p>
<div data-kugelaudio-widget></div>

To ask for an explicit yes, show the button only once the visitor agrees. Keep the widget's script tag with data-position="inline" on the page: the widget also fills a data-kugelaudio-widget element that is added later.

html
<label><input type="checkbox" id="agree"> I agree that this call is recorded.</label>
<div id="call"></div>

<script>
  document.getElementById('agree').addEventListener('change', (event) => {
    const call = document.getElementById('call')
    if (!event.target.checked || call.firstChild) return
    const spot = document.createElement('div')
    spot.setAttribute('data-kugelaudio-widget', '')
    call.append(spot)
  })
</script>

A phone call has no page. Let the agent speak first on a call (firstTurn: "agent" in src/agent/agent.tsx, as the template has it) and tell it in its <SystemPrompt> to say that the call is recorded before anything else. The model says it in its own words.

Ask for feedback at the end of a call

Make the question a tool. When the caller answers, the agent calls the tool, and the tool's arguments are stored with the call.

In src/agent/agent.tsx, put the tool in <GlobalTools>, beside <Skills>, so that it exists wherever the conversation ends. GlobalTools, Tool and z come from "curb".

TSX
<GlobalTools>
  <Tool
    name="record_feedback"
    description="Record the caller's answer to the feedback question at the end of the call."
    schema={z.object({
      rating: z
        .number()
        .int()
        .min(1, "Ask for a number from 1 to 5.")
        .max(5, "Ask for a number from 1 to 5.")
        .describe("The caller's rating, from 1 (poor) to 5 (excellent)."),
      comment: z
        .string()
        .max(500)
        .optional()
        .describe("Anything else the caller said about the call, in their words."),
    })}
    success="Feedback recorded."
    onSubmit={(input) => ({ ok: true, data: input })}
  />
</GlobalTools>

Then tell the agent when to ask, in its <SystemPrompt>:

Text
When the caller has what they called for, ask: "Before you go, how would you rate this
call from 1 to 5?" Call record_feedback with the answer, then say goodbye and end the
call. If the caller does not want to answer, end the call without asking again.

Open a call on the agent's Calls page: its Activity tab shows record_feedback with the rating and the comment. A call that starts while the agent keeps nothing stores no activity (see the recording notice). To keep every answer in a system of your own, send it there from onSubmit, as in the next recipe.

Call an HTTPS API with a secret

A tool can call any HTTPS API on the internet (see what your agent can reach). Keep the API's credential in a variable on the agent, and read it only inside the tool. This example asks a calendar API for free slots with an app-only credential, one that belongs to your application rather than to a person.

  1. Put the credential in .env, which npm run dev reads:

    Bash
    CALENDAR_CLIENT_ID=...
    CALENDAR_CLIENT_SECRET=...

    Send it to the agent with npx curb secrets push, or set it on the agent's Variables page.

  2. Add the names to curb.requiredRuntimeSecrets in package.json. A version whose required variables are not set on the agent never goes live, so a forgotten secret fails the deploy rather than a call.

    JSON
    {
      "curb": {
        "requiredRuntimeSecrets": [
          "ANTHROPIC_API_KEY",
          "KUGELAUDIO_API_KEY",
          "CALENDAR_CLIENT_ID",
          "CALENDAR_CLIENT_SECRET"
        ]
      }
    }
  3. Read the variables in the tool, each time it runs:

    TSX
    import { defineTool, z } from "curb";
    
    // An app-only token from the calendar's sign-in server (OAuth client credentials).
    async function calendarToken(): Promise<string> {
      const clientId = process.env.CALENDAR_CLIENT_ID;
      const clientSecret = process.env.CALENDAR_CLIENT_SECRET;
      if (!clientId || !clientSecret) throw new Error("CALENDAR_CLIENT_ID or CALENDAR_CLIENT_SECRET is not set");
      const response = await fetch("https://login.example.com/oauth2/token", {
        method: "POST",
        body: new URLSearchParams({
          grant_type: "client_credentials",
          client_id: clientId,
          client_secret: clientSecret,
        }),
      });
      if (!response.ok) throw new Error(`Calendar token request failed with HTTP ${response.status}`);
      const { access_token } = (await response.json()) as { access_token: string };
      return access_token;
    }
    
    export const findFreeSlots = defineTool({
      name: "find_free_slots",
      description: "Find the free appointment slots on one day.",
      schema: z.object({
        date: z
          .string()
          .regex(/^\d{4}-\d{2}-\d{2}$/, "Ask which day, and pass it as YYYY-MM-DD.")
          .describe("The day, as YYYY-MM-DD."),
      }),
      onSubmit: async ({ date }) => {
        const response = await fetch(`https://calendar.example.com/v1/free-slots?date=${date}`, {
          headers: { Authorization: `Bearer ${await calendarToken()}` },
        });
        if (!response.ok) return { ok: false, message: "The calendar is not answering. Offer to call back later." };
        const { slots } = (await response.json()) as { slots: string[] };
        return { ok: true, data: { slots } };
      },
    });

    Render it in the skill that books appointments with <Tool def={findFreeSlots} />.

Keep the secret out of everything the model reads:

  • Never put a secret in the system prompt, an instruction, a skill's text, or a tool's result or message. The model reads all of them, and it may say aloud anything it reads.
  • Keep it out of error messages and logs: what your agent prints goes to its logs. An error the tool throws reaches the model only as a generic failure.
  • Read it from process.env inside the tool, as above, never when the file loads. A hosted agent may load your files before its variables are set.

Test a change on a test copy first

Keep a second agent as a test copy. Deploy each change to it first, call it, and deploy the same project to your live agent only once it works. Until then your live agent keeps answering its numbers and widgets with the version it has.

With your coding agent: ask it to create the test copy (create_agent), give it the same variables (set_agent_secrets), deploy the project to it and give you its Playground link. Once your call works, ask it to deploy the same project to the live agent.

From a terminal:

Bash
cat .curb/link.json      # "agentId" is your live agent's id: note it
rm .curb/link.json
npx curb secrets push    # choose "Create a new agent" and name it, for example my-agent-test
npx curb deploy          # deploys the test copy and ends with its Playground link

curb secrets push prints the test copy's id when it creates it. Call the test copy in its Playground. When it works, deploy the same project to the live agent:

Bash
npx curb deploy --agent <live-agent-id>

--agent also links the project to the agent you name, so name one on every deploy and secrets push from then on: --agent <test-agent-id> first, then --agent <live-agent-id>.

The test copy needs the same variables as the live agent. Its calls are billed like any other (see pricing). To call it from your phone, add your phone to its test line.

Next steps