Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/realtime-speech-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add a Vercel realtime speech channel (exported at `eve/channels/vercel/speech`) plus a React voice hook. The channel mints AI Gateway realtime client secrets so the browser can hold the audio socket, while finalized transcripts run as ordinary durable turns through the existing `/eve/v1/session` routes and event stream — no Eve request blocks for a full model turn, and spoken replies are read back on `message.completed`. `eve/react/voice` provides the browser hook (`useEveVoice`); non-React clients use `setupVoice` plus `client.session()`.
37 changes: 37 additions & 0 deletions apps/docs/lib/integrations/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,43 @@ export default eveChannel();
Point your frontend at the session routes eve serves (\`/eve/v1/session\`) and stream responses with the eve web client.`,
configure: `The eve channel is the lowest-friction way to talk to your agent, with no third-party provisioning required. Layer in auth and route protection as needed. See the [eve channel docs](/docs/channels/eve) and the [Frontend guide](/docs/guides/frontend/overview).`,
},
"realtime-speech": {
logo: "voice",
docsHref: "/docs/channels/realtime-speech",
keywords: ["voice", "audio", "realtime", "microphone", "ai gateway"],
install: `Install the framework and the AI SDK React realtime peer:

\`\`\`bash
npm install eve@latest @ai-sdk/react
\`\`\``,
quickStart: `Create \`agent/channels/speech.ts\`:

\`\`\`ts
// agent/channels/speech.ts
import { vercelSpeechChannel } from "eve/channels/vercel/speech";
import { localDev, vercelOidc } from "eve/channels/auth";

export default vercelSpeechChannel({
auth: [localDev(), vercelOidc()],
});
\`\`\`

Then render a microphone wherever it fits your UI:

\`\`\`tsx
"use client";

import { useEveVoice } from "eve/react/voice";

export function ComposerActions() {
const voice = useEveVoice();
const active = voice.status === "connected" || voice.status === "connecting";

return <button onClick={() => (active ? voice.stop() : void voice.start())}>Talk</button>;
}
\`\`\``,
configure: `Set \`AI_GATEWAY_API_KEY\` so the setup route can mint short-lived AI Gateway realtime client secrets. The browser keeps the realtime audio socket open, while each finalized utterance runs as an ordinary durable turn through the existing \`/eve/v1/session\` routes and event stream. The voice session id is a client-visible correlation id only; principal binding comes from normal session-route auth.`,
},
};

/**
Expand Down
20 changes: 20 additions & 0 deletions apps/docs/lib/integrations/logos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,25 @@ export const webLogo = (props: LogoProps) => (
</svg>
);

export const voiceLogo = (props: LogoProps) => (
<svg fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M12 3.75a3 3 0 0 0-3 3v4.5a3 3 0 1 0 6 0v-4.5a3 3 0 0 0-3-3Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
<path
d="M5.75 10.75a6.25 6.25 0 0 0 12.5 0M12 17v3.25M8.75 20.25h6.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
);

export const githubLogo = (props: LogoProps) => (
<svg fill="none" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
Expand Down Expand Up @@ -136,6 +155,7 @@ export const honeycombLogo = (props: LogoProps) => (
export const logos = {
eve: eveLogo,
web: webLogo,
voice: voiceLogo,
github: githubLogo,
slack: slackLogo,
discord: discordLogo,
Expand Down
32 changes: 32 additions & 0 deletions apps/frameworks/next/agent/channel-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth";
import { getAuthJsSession } from "@/lib/auth";

function authjsSession(): AuthFn<Request> {
return async (request) => {
const session = await getAuthJsSession(request);
if (!session) return null;

const attributes: Record<string, string> = {
providerId: session.providerId,
};
if (session.profile.email) {
attributes.email = session.profile.email;
}
if (session.profile.name) {
attributes.name = session.profile.name;
}
if (session.profile.image) {
attributes.image = session.profile.image;
}
return {
attributes,
authenticator: "authjs",
issuer: session.issuer,
principalId: session.profile.sub,
principalType: "user",
subject: session.profile.sub,
};
};
}

export const agentChannelAuth = [authjsSession(), localDev(), vercelOidc()] as const;
33 changes: 2 additions & 31 deletions apps/frameworks/next/agent/channels/eve.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,6 @@
import { type AuthFn, localDev, vercelOidc } from "eve/channels/auth";
import { eveChannel } from "eve/channels/eve";
import { getAuthJsSession } from "@/lib/auth";

function authjsSession(): AuthFn<Request> {
return async (request) => {
const session = await getAuthJsSession(request);
if (!session) return null;

const attributes: Record<string, string> = {
providerId: session.providerId,
};
if (session.profile.email) {
attributes.email = session.profile.email;
}
if (session.profile.name) {
attributes.name = session.profile.name;
}
if (session.profile.image) {
attributes.image = session.profile.image;
}
return {
attributes,
authenticator: "authjs",
issuer: session.issuer,
principalId: session.profile.sub,
principalType: "user",
subject: session.profile.sub,
};
};
}
import { agentChannelAuth } from "../channel-auth";

export default eveChannel({
auth: [authjsSession(), localDev(), vercelOidc()],
auth: agentChannelAuth,
});
45 changes: 45 additions & 0 deletions apps/frameworks/next/agent/channels/realtime-speech.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createGateway } from "@ai-sdk/gateway";
import {
vercelSpeechChannel,
type VercelSpeechChannelInput,
type VercelSpeechGetTokenInput,
} from "eve/channels/vercel/speech";
import { agentChannelAuth } from "../channel-auth";

const gatewayBaseUrl =
process.env.AI_GATEWAY_BASE_URL?.trim() || process.env.AI_GATEWAY_BASEURL?.trim();
const gatewayBypass =
process.env.VERCEL_AUTOMATION_BYPASS_SECRET?.trim() || process.env.VERCEL_DPBP?.trim();

const gateway =
gatewayBaseUrl !== undefined && gatewayBaseUrl.length > 0
? createGateway({
baseURL: gatewayBaseUrl,
...(gatewayBypass !== undefined && gatewayBypass.length > 0
? { headers: { "x-vercel-protection-bypass": gatewayBypass } }
: {}),
})
: undefined;

function withGatewayBypass(url: string): string {
if (gatewayBypass === undefined || gatewayBypass.length === 0) return url;
const parsed = new URL(url);
parsed.searchParams.set("x-vercel-protection-bypass", gatewayBypass);
return parsed.toString();
}

export default vercelSpeechChannel({
auth: agentChannelAuth,
control:
process.env.EVE_REALTIME_CONTROL === "1" || process.env.NEXT_PUBLIC_EVE_VOICE_CONTROL === "1",
expiresAfterSeconds: 300,
...(gateway === undefined
? {}
: {
async getToken(input: VercelSpeechGetTokenInput) {
const token = await gateway.experimental_realtime.getToken(input);
return { ...token, url: withGatewayBypass(token.url) };
},
}),
model: process.env.EVE_REALTIME_MODEL?.trim() || "openai/gpt-realtime-2",
} satisfies VercelSpeechChannelInput);
Loading
Loading