diff --git a/apps/docs/app/[lang]/templates/[slug]/file-viewer.tsx b/apps/docs/app/[lang]/templates/[slug]/file-viewer.tsx index e98c8cf98..f3995d69a 100644 --- a/apps/docs/app/[lang]/templates/[slug]/file-viewer.tsx +++ b/apps/docs/app/[lang]/templates/[slug]/file-viewer.tsx @@ -32,6 +32,7 @@ interface CategoryStyle { const categoryStyles: Record = { "agent.ts": { icon: SettingsIcon }, "instructions.md": { icon: FileTextIcon }, + "instructions.ts": { icon: FileTextIcon }, channels: { icon: MessageSquareIcon }, connections: { icon: PlugIcon }, skills: { icon: FileTextIcon }, @@ -44,6 +45,7 @@ const defaultStyle: CategoryStyle = { icon: FileTextIcon }; const categoryOrder = [ "agent.ts", "instructions.md", + "instructions.ts", "channels", "connections", "skills", diff --git a/apps/docs/app/[lang]/templates/integration-icons.tsx b/apps/docs/app/[lang]/templates/integration-icons.tsx index 034448267..5c12400e5 100644 --- a/apps/docs/app/[lang]/templates/integration-icons.tsx +++ b/apps/docs/app/[lang]/templates/integration-icons.tsx @@ -1,14 +1,26 @@ import { BracesIcon } from "lucide-react"; import type { ComponentType, SVGProps } from "react"; -import { linearLogo, notionLogo, sentryLogo, slackLogo, webLogo } from "@/lib/integrations/logos"; +import { + githubLogo, + linearLogo, + notionLogo, + nuxtLogo, + sendblueLogo, + sentryLogo, + slackLogo, + webLogo, +} from "@/lib/integrations/logos"; import type { TemplateIntegration } from "@/lib/templates/data"; type IconComponent = ComponentType>; export const integrationIcons: Record = { + GitHub: githubLogo, "HTTP API": BracesIcon, Linear: linearLogo, Notion: notionLogo, + Nuxt: nuxtLogo, + Sendblue: sendblueLogo, Sentry: sentryLogo, Slack: slackLogo, "Web chat": webLogo, diff --git a/apps/docs/lib/integrations/logos.tsx b/apps/docs/lib/integrations/logos.tsx index 6c2697219..58901d83c 100644 --- a/apps/docs/lib/integrations/logos.tsx +++ b/apps/docs/lib/integrations/logos.tsx @@ -16,6 +16,7 @@ import { SiMixpanel, SiNetlify, SiNotion, + SiNuxt, SiOreilly, SiPlanetscale, SiPosthog, @@ -220,6 +221,8 @@ export const mixpanelLogo = (props: LogoProps) => ; +export const nuxtLogo = (props: LogoProps) => ; + export const oreillyLogo = (props: LogoProps) => ; export const planetscaleLogo = (props: LogoProps) => ; diff --git a/apps/docs/lib/templates/data.ts b/apps/docs/lib/templates/data.ts index e6161bfec..279b17f89 100644 --- a/apps/docs/lib/templates/data.ts +++ b/apps/docs/lib/templates/data.ts @@ -2,9 +2,12 @@ export type TemplateCategory = "Chat" | "Collaboration" | "Example"; import { templateSourceFiles } from "./sources"; export type TemplateIntegration = + | "GitHub" | "HTTP API" | "Linear" | "Notion" + | "Nuxt" + | "Sendblue" | "Sentry" | "Slack" | "Web chat"; @@ -76,6 +79,21 @@ export const templateEntries: TemplateEntry[] = [ source: "Vercel Templates", files: templateSourceFiles["eve-slack-agent"], }, + { + slug: "personal-agent", + title: "Personal", + setupPrompt: + "Set up the eve personal agent template in my current workspace using https://github.com/vercel-labs/personal-agent-template/tree/main as the source. Copy the project files, install its dependencies, and follow the repository README and docs/ENVIRONMENT.md to configure it, including the Nuxt web app, the Slack and Sendblue channels, and the GitHub and Linear connections. Preserve the existing project if the workspace is not empty, and tell me about any required environment variables or manual setup steps.", + description: + "A personal assistant you reach from web chat, Slack, or iMessage, with long-term memory it only saves once you approve, plus GitHub and Linear on tap.", + sourceHref: "https://github.com/vercel-labs/personal-agent-template/tree/main", + sourceRevision: "ec986e56130167ccf0017000380735e65882849c", + category: "Chat", + model: "anthropic/claude-sonnet-4.6", + integrations: ["Web chat", "Nuxt", "Slack", "Sendblue", "GitHub", "Linear"], + source: "Vercel Templates", + files: templateSourceFiles["personal-agent"], + }, { slug: "weather-agent-fixture", title: "Weather", diff --git a/apps/docs/lib/templates/sources.ts b/apps/docs/lib/templates/sources.ts index 85018d1e5..af3e3528d 100644 --- a/apps/docs/lib/templates/sources.ts +++ b/apps/docs/lib/templates/sources.ts @@ -458,6 +458,1454 @@ export default defineTool({ return { city, condition: "Sunny", temperatureF: 72 }; }, }); +`, + ), + ], + "personal-agent": [ + file( + "agent/agent.ts", + "typescript", + `import { defineAgent } from "eve"; + +export default defineAgent({ + model: "anthropic/claude-sonnet-4.6", + modelOptions: { + providerOptions: { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: 2048, + }, + }, + }, + }, +}); +`, + ), + file( + "agent/channels/eve.ts", + "typescript", + `import type { AuthFn } from "eve/channels/auth"; +import { eveChannel } from "eve/channels/eve"; +import { vercelOidc } from "eve/channels/auth"; +import { auth } from "../../auth"; + +function appSession(): AuthFn { + return async (request) => { + const session = await auth.api.getSession({ + headers: request.headers, + }); + + if (!session?.user) { + return null; + } + + return { + attributes: { + email: session.user.email, + name: session.user.name, + }, + authenticator: "app", + issuer: "app", + principalId: session.user.id, + principalType: "user", + }; + }; +} + +export default eveChannel({ + auth: [ + appSession(), + vercelOidc(), + ], +}); +`, + ), + file( + "agent/channels/sendblue.ts", + "typescript", + `import type { SendFn, SendOptions } from "eve/channels"; +import { defineChannel, POST } from "eve/channels"; +import type { SendblueMessagePayload } from "chat-adapter-sendblue"; +import { agent } from "../../shared/agent.js"; +import { buildAppSessionAuth } from "../../shared/slack-auth.js"; +import { fetchPhoneLinkForNumber } from "../lib/phone-internal.js"; +import { + contactNumberFromPayload, + getSendblueAdapter, + isInboundSendblueMessage, + isSendblueConfigured, + isSendblueServiceAllowed, + profileSettingsUrl, + resolveSendblueLineNumber, + threadIdFromPayload, + verifySendblueWebhook, +} from "../lib/sendblue.js"; + +const WEBHOOK_ROUTE = "/eve/v1/sendblue/webhook"; + +const IMESSAGE_CHANNEL_CONTEXT = [ + "Channel: iMessage (Sendblue). There is no browser UI in this thread.", + "Answer the user's question directly with tools when needed.", + "Do not call save_memory unless they explicitly ask you to remember or save something.", +] as const; + +interface PendingInputRequest { + requestId: string; + toolName: string; +} + +interface SendblueChannelState { + threadId: string | null; + contactNumber: string | null; + fromNumber: string | null; + groupId: string | null; + isGroup: boolean; + pendingToolCallMessage: string | null; +} + +interface SendblueChannelContext { + sendblue: ReturnType; + state: SendblueChannelState; +} + +function firstNonEmptyLine(text: string) { + for (const line of text.split(/\\r?\\n/u)) { + const trimmed = line.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + + return undefined; +} + +async function postToThread(threadId: string, message: string) { + try { + const sendblue = getSendblueAdapter(); + await sendblue.postMessage(threadId, { markdown: message }); + } catch (error) { + console.error("[sendblue] outbound delivery failed", error); + } +} + +function threadIdForState( + sendblue: ReturnType, + state: Pick, +) { + if (state.fromNumber && state.contactNumber) { + return sendblue.encodeThreadId({ + fromNumber: state.fromNumber, + contactNumber: state.contactNumber, + }); + } + + return state.threadId; +} + +const pendingInputByThread = new Map(); + +interface InflightSend { + send: SendFn; + auth: SendOptions["auth"]; + continuationToken: string; + state: SendblueChannelState; +} + +let inflightSend: InflightSend | null = null; + +function parseApprovalReply(text: string): "approve" | "deny" | null { + const normalized = text.trim().toLowerCase(); + if (/^(yes|y|oui|ok|approve|remember)$/u.test(normalized)) { + return "approve"; + } + if (/^(no|n|non|skip|deny)$/u.test(normalized)) { + return "deny"; + } + return null; +} + +function isSaveMemoryRequest(request: PendingInputRequest) { + return request.toolName === "save_memory"; +} + +function denyResponses(requests: readonly PendingInputRequest[]) { + return requests.map(request => ({ + requestId: request.requestId, + optionId: "deny" as const, + })); +} + +async function resolvePendingInput( + threadId: string, + text: string, + send: SendFn, + sendOptions: SendOptions, +) { + const pending = pendingInputByThread.get(threadId); + if (!pending?.length) { + return false; + } + + const onlySaveMemory = pending.every(isSaveMemoryRequest); + const approval = onlySaveMemory ? "deny" : parseApprovalReply(text); + + if (!approval) { + await postToThread( + threadId, + onlySaveMemory + ? \`Skipping memory save — edit your profile at \${profileSettingsUrl()}.\` + : "Reply YES to approve or NO to skip the pending action.", + ); + return true; + } + + pendingInputByThread.delete(threadId); + + try { + inflightSend = { + send, + auth: sendOptions.auth, + continuationToken: sendOptions.continuationToken, + state: sendOptions.state, + }; + await send( + { inputResponses: pending.map(request => ({ + requestId: request.requestId, + optionId: approval, + })) }, + sendOptions, + ); + } finally { + inflightSend = null; + } + + if (onlySaveMemory) { + await postToThread( + threadId, + \`Memory saves are not available in iMessage. Edit your profile at \${profileSettingsUrl()}.\`, + ); + return false; + } + + return true; +} + +async function dispatchInbound( + payload: SendblueMessagePayload, + send: SendFn, +) { + const sendblue = getSendblueAdapter(); + const threadId = threadIdFromPayload(payload, sendblue); + const contactNumber = contactNumberFromPayload(payload); + const text = payload.content?.trim() ?? ""; + + if (!text) { + return; + } + + const link = await fetchPhoneLinkForNumber(contactNumber); + if (!link) { + await postToThread( + threadId, + [ + \`Your phone number is not linked to \${agent.name} yet.\`, + "", + \`Add it in \${profileSettingsUrl()} using E.164 format (for example +33612345678), then message again.\`, + ].join("\\n"), + ); + return; + } + + const auth = buildAppSessionAuth(link.appUserId, { + channel: "sendblue", + phone_number: contactNumber, + }); + + const fromNumber = resolveSendblueLineNumber(payload); + + const sendOptions = { + auth, + continuationToken: threadId, + state: { + threadId, + contactNumber, + fromNumber, + groupId: payload.group_id?.length ? payload.group_id : null, + isGroup: Boolean(payload.group_id?.length), + pendingToolCallMessage: null, + } satisfies SendblueChannelState, + }; + + try { + const blocked = await resolvePendingInput(threadId, text, send, sendOptions); + if (blocked) { + return; + } + + inflightSend = { send, auth, continuationToken: threadId, state: sendOptions.state }; + await send( + { + message: text, + context: [...IMESSAGE_CHANNEL_CONTEXT], + }, + sendOptions, + ); + } catch (error) { + console.error("[sendblue] agent send failed", error); + } finally { + inflightSend = null; + } +} + +export default defineChannel({ + kindHint: "sendblue", + + state: { + threadId: null, + contactNumber: null, + fromNumber: null, + groupId: null, + isGroup: false, + pendingToolCallMessage: null, + }, + + metadata(state) { + return { + contactNumber: state.contactNumber, + fromNumber: state.fromNumber, + isGroup: state.isGroup, + threadId: state.threadId, + }; + }, + + context(state) { + return { + sendblue: getSendblueAdapter(), + state, + }; + }, + + routes: [ + POST(WEBHOOK_ROUTE, async (request, { send, waitUntil }) => { + if (!isSendblueConfigured()) { + return new Response("Sendblue is not configured", { status: 503 }); + } + + if (!verifySendblueWebhook(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response("Bad Request", { status: 400 }); + } + + if (body && typeof body === "object" && "is_typing" in body) { + return new Response("OK", { status: 200 }); + } + + if (!isInboundSendblueMessage(body)) { + return new Response("OK", { status: 200 }); + } + + const payload = body; + + if (!isSendblueServiceAllowed(payload.service)) { + return new Response("OK", { status: 200 }); + } + + if (payload.is_outbound || payload.status !== "RECEIVED") { + return new Response("OK", { status: 200 }); + } + + if (payload.group_id?.length) { + const sendblue = getSendblueAdapter(); + const threadId = threadIdFromPayload(payload, sendblue); + waitUntil( + postToThread( + threadId, + \`Group chats are not supported yet. Message \${agent.name} in a direct conversation instead.\`, + ), + ); + return new Response("OK", { status: 200 }); + } + + waitUntil(dispatchInbound(payload, send)); + return new Response("OK", { status: 200 }); + }), + ], + + events: { + async "turn.started"(_event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId || channel.state.isGroup) { + return; + } + + await channel.sendblue.startTyping(threadId).catch(() => undefined); + }, + + async "actions.requested"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId || channel.state.isGroup) { + return; + } + + const pending = channel.state.pendingToolCallMessage; + channel.state.pendingToolCallMessage = null; + + if (pending) { + await postToThread(threadId, pending); + return; + } + + await channel.sendblue.startTyping(threadId).catch(() => undefined); + void event; + }, + + async "message.completed"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId) { + return; + } + + if (event.finishReason === "tool-calls") { + const pending = event.message + ? firstNonEmptyLine(event.message) ?? null + : null; + channel.state.pendingToolCallMessage = pending; + + if (pending) { + await postToThread(threadId, pending); + } else { + await postToThread(threadId, "Working on that — I'll reply in a moment."); + } + return; + } + + channel.state.pendingToolCallMessage = null; + + if (!event.message) { + return; + } + + await postToThread(threadId, event.message); + }, + + async "input.requested"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId || event.requests.length === 0) { + return; + } + + const pending = event.requests.map(request => ({ + requestId: request.requestId, + toolName: request.action.toolName, + })); + const onlySaveMemory = pending.every(isSaveMemoryRequest); + + if (onlySaveMemory && inflightSend) { + await postToThread( + threadId, + \`Memory saves need the web profile on iMessage — skipping. Edit at \${profileSettingsUrl()}.\`, + ); + try { + await inflightSend.send( + { inputResponses: denyResponses(pending) }, + { + auth: inflightSend.auth, + continuationToken: inflightSend.continuationToken, + state: channel.state, + }, + ); + } catch (error) { + console.error("[sendblue] save_memory auto-deny failed", error); + } + return; + } + + pendingInputByThread.set(threadId, pending); + + if (onlySaveMemory) { + await postToThread( + threadId, + \`Memory saves are not available in iMessage. Edit your profile at \${profileSettingsUrl()}.\`, + ); + return; + } + + const prompts = event.requests.map(request => request.prompt).join("\\n\\n"); + await postToThread( + threadId, + [ + prompts, + "", + "Reply YES to approve or NO to skip.", + ].join("\\n"), + ); + }, + + async "authorization.required"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId) { + return; + } + + const url = event.authorization?.url; + const userCode = event.authorization?.userCode; + const lines = url + ? [ + \`Sign in to \${event.name} to continue: \${url}\`, + ...(userCode ? [\`Code: \${userCode}\`] : []), + ] + : [ + \`Authorization is required for \${event.name}.\`, + \`Open \${profileSettingsUrl()} to connect integrations, then try again.\`, + ]; + + await postToThread(threadId, lines.join("\\n")); + }, + + async "turn.failed"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId) { + return; + } + + await postToThread( + threadId, + [ + "I hit an error while handling your request.", + "", + "Please try again, rephrase, or open the web chat if it keeps failing.", + ].join("\\n"), + ); + + void event; + }, + + async "session.failed"(event, channel) { + const threadId = threadIdForState(channel.sendblue, channel.state); + if (!threadId) { + return; + } + + await postToThread( + threadId, + [ + "This session could not recover from an error.", + "", + "Send a new message to start again.", + ].join("\\n"), + ); + + void event; + }, + }, +}); +`, + ), + file( + "agent/channels/slack.ts", + "typescript", + `import { connectSlackCredentials } from "@vercel/connect/eve"; +import { + defaultSlackAuth, + loadThreadContextMessages, + slackChannel, + type SlackContext, + type SlackMessage, +} from "eve/channels/slack"; +import { buildAppSessionAuth } from "../../shared/slack-auth"; +import { + consumeSlackLinkCodeRemote, + fetchSlackLinkForMember, + parseSlackLinkCommand, +} from "../lib/slack-internal"; + +async function slackUserProfile(ctx: SlackContext, userId: string) { + const res = await ctx.slack.request("users.info", { user: userId }); + if (!res.ok || typeof res.user !== "object" || res.user === null) return null; + + const user = res.user as { + name?: string; + real_name?: string; + profile?: { display_name?: string; real_name?: string; email?: string }; + }; + + const displayName = + user.profile?.display_name?.trim() || + user.profile?.real_name?.trim() || + user.real_name?.trim() || + user.name; + + return { + userId, + userName: user.name, + displayName, + email: user.profile?.email, + }; +} + +async function tryHandleSlackLinkCommand( + ctx: SlackContext, + message: SlackMessage, +) { + const userId = message.author?.userId; + const teamId = message.teamId; + const text = message.markdown ?? message.text ?? ""; + + if (!userId || !teamId) { + return false; + } + + const code = parseSlackLinkCommand(text); + if (!code) { + return false; + } + + const profile = await slackUserProfile(ctx, userId); + const result = await consumeSlackLinkCodeRemote({ + code, + slackTeamId: teamId, + slackUserId: userId, + slackUserName: profile?.userName ?? message.author?.userName, + slackDisplayName: profile?.displayName ?? message.author?.fullName, + slackEmail: profile?.email, + }); + + if (result.ok) { + await ctx.thread.post( + "Your Slack account is now linked to V. Mentions and DMs will use your profile and integrations.", + ); + return true; + } + + const reason = result.reason === "expired" + ? "That link code has expired. Generate a new one in V → Integrations." + : "That link code is invalid. Generate a fresh code in V → Integrations."; + + await ctx.thread.post(reason); + return true; +} + +async function resolveSlackInboundAuth( + slackAuth: ReturnType, + member: { + teamId?: string | null; + userId: string; + userName?: string; + displayName?: string; + email?: string; + }, +) { + if (!member.teamId) { + return slackAuth; + } + + const link = await fetchSlackLinkForMember(member.teamId, member.userId); + if (!link) { + return slackAuth; + } + + return buildAppSessionAuth(link.appUserId, { + email: member.email ?? link.slackEmail, + name: member.displayName ?? link.slackDisplayName, + slack_team_id: member.teamId, + slack_user_id: member.userId, + slack_user_name: member.userName ?? link.slackUserName, + linked: "true", + }); +} + +async function buildSlackTurn(ctx: SlackContext, message: SlackMessage) { + if (await tryHandleSlackLinkCommand(ctx, message)) { + return null; + } + + await ctx.thread.startTyping("Thinking…"); + + const context: string[] = []; + const userId = message.author?.userId; + let profile: Awaited> = null; + + if (userId) { + profile = await slackUserProfile(ctx, userId); + if (profile?.displayName) { + context.push( + [ + "Slack user speaking in this thread:", + \`- Display name: \${profile.displayName}\`, + profile.userName ? \`- Username: @\${profile.userName}\` : null, + \`- User ID: \${profile.userId}\`, + profile.email ? \`- Email: \${profile.email}\` : null, + ] + .filter(Boolean) + .join("\\n"), + ); + } + } + + const prior = await loadThreadContextMessages(ctx.thread, message, { + since: "last-agent-reply", + }); + if (prior.length > 0) { + const transcript = prior + .map((m) => \`\${m.isMe ? "V" : (m.user ?? "user")}: \${m.markdown}\`) + .join("\\n"); + context.push(\`Recent thread messages since your last reply:\\n\\n\${transcript}\`); + } + + const slackAuth = defaultSlackAuth(message, ctx); + if (!slackAuth || !userId) { + return null; + } + + const auth = await resolveSlackInboundAuth(slackAuth, { + teamId: message.teamId, + userId, + userName: profile?.userName ?? message.author?.userName, + displayName: profile?.displayName ?? message.author?.fullName, + email: profile?.email, + }); + + const linked = auth.principalId !== slackAuth.principalId; + if (!linked) { + const linkUrl = process.env.BETTER_AUTH_URL + ? \`\${process.env.BETTER_AUTH_URL.replace(/\\/$/, "")}/settings/integrations\` + : "V → Integrations"; + context.push( + \`This Slack account is not linked to a V profile yet. Open \${linkUrl}, generate a link code, then message \\\`link \\\` here.\`, + ); + } + + return { + auth, + context: context.length > 0 ? context : undefined, + }; +} + +// Replace with your Vercel Connect Slack slug (e.g. "slack/your-agent"). +export default slackChannel({ + credentials: connectSlackCredentials("slack/v"), + + async onAppMention(ctx, message) { + return buildSlackTurn(ctx, message); + }, + + async onDirectMessage(ctx, message) { + return buildSlackTurn(ctx, message); + }, +}); +`, + ), + file( + "agent/connections/linear.ts", + "typescript", + `import { connect } from "@vercel/connect/eve"; +import { defineMcpClientConnection } from "eve/connections"; + +const CONNECTOR = "mcp.linear.app/linear"; +const USER_ISSUER = "app"; + +const connectAuth = connect({ + connector: CONNECTOR, + validate: true, + principalToSubject: (principal) => ({ + type: "user", + id: principal.id, + issuer: principal.issuer ?? principal.authenticator ?? USER_ISSUER, + }), +}); + +async function completeAuthorizationWithRetry( + opts: Parameters>[0], +) { + const delays = [0, 500, 1000, 2000]; + let lastError: unknown; + + for (const delay of delays) { + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + try { + return await connectAuth.completeAuthorization!(opts); + } + catch (error) { + lastError = error; + } + } + + throw lastError; +} + +export default defineMcpClientConnection({ + url: "https://mcp.linear.app/mcp", + description: "Linear workspace: issues, projects, cycles, and comments.", + auth: { + ...connectAuth, + completeAuthorization: completeAuthorizationWithRetry, + }, +}); +`, + ), + file( + "agent/instructions.ts", + "typescript", + `import { defineDynamic, defineInstructions } from "eve/instructions"; +import type { DynamicResolveContext } from "eve/instructions"; +import { BASE_INSTRUCTIONS } from "./lib/base-instructions.js"; +import { buildUserContextPrompt, fetchUserContext } from "./lib/memory-internal.js"; + +const IMESSAGE_INSTRUCTIONS = \` + +# iMessage (Sendblue) + +- This conversation is over iMessage. There is no browser UI for tool approvals in this thread. +- Answer the user's question directly. Use Linear, weather, and other tools when relevant. +- Do **not** call \\\`save_memory\\\` unless the user explicitly asks you to remember or save something. +- If they want to update long-term memory, tell them to edit **Settings → Profile** on the web app.\`; + +function instructionsForChannel(kind: string | undefined, base: string) { + if (kind === "sendblue") { + return \`\${base}\${IMESSAGE_INSTRUCTIONS}\`; + } + return base; +} + +export default defineDynamic({ + events: { + "session.started": async (_event, ctx: DynamicResolveContext) => { + const userId = ctx.session.auth.current?.principalId; + if (!userId || userId.startsWith("eve:")) { + return defineInstructions({ + markdown: instructionsForChannel(ctx.channel.kind, BASE_INSTRUCTIONS), + }); + } + + const context = await fetchUserContext(userId); + if (!context) { + return defineInstructions({ + markdown: instructionsForChannel(ctx.channel.kind, BASE_INSTRUCTIONS), + }); + } + + const userBlock = buildUserContextPrompt(context); + return defineInstructions({ + markdown: instructionsForChannel( + ctx.channel.kind, + \`\${BASE_INSTRUCTIONS}\\n\\n---\\n\\n\${userBlock}\`, + ), + }); + }, + }, +}); +`, + ), + file( + "agent/lib/base-instructions.ts", + "typescript", + `import { agent } from "../../shared/agent.js"; + +// Customize agent persona, tone, and behavior rules. +export const BASE_INSTRUCTIONS = \`# Identity + +You are **\${agent.name}**, a personal AI assistant. You are not a generic chatbot — you have a consistent personality, you know your name, and you stay the same across every conversation and channel. + +\${agent.name} runs on [Eve](https://eve.dev), a durable agent framework. You may be reached from a web chat today and from other surfaces (iMessage, GitHub, etc.) over time — always as the same assistant. + +# Tone + +- Concise and technically precise. No filler, no sycophancy. +- Warm and direct — like a trusted sidekick, not a corporate helpdesk. +- Match the user's language. Reply in French when they write in French, in English when they write in English. + +# Behavior + +- Use tools proactively when they help answer the question. You have file, shell, web, delegation, \\\`weather\\\`, \\\`save_memory\\\`, Linear (when connected), and GitHub (when connected) by default. +- Use \\\`weather\\\` when the user asks about weather, temperature, or conditions for a place. Summarize the result briefly (location, condition, temperature). +- Prefer doing the work over describing what you could do. +- For destructive or sensitive actions, state briefly what you are about to do before proceeding. +- If you do not know something, say so. Do not invent facts, URLs, or tool results. + +# Memory + +- The user's long-term memory and profile are injected below when available. Treat them as authoritative context. +- When the user shares a lasting preference, working rule, or stable personal/professional fact, use \\\`save_memory\\\` so they can approve storing it. Do not save ephemeral task details, one-off requests, or information they did not imply should be remembered. +- Each memory category holds **one** prose block. \\\`save_memory\\\` **replaces** the whole category — always send the full updated text for that category, not a partial delta. +- Use **one** \\\`save_memory\\\` call per assistant turn. Put every affected category in \\\`updates\\\` — never call \\\`save_memory\\\` twice in parallel. +- If the user asks to change or remove something from memory, propose the full rewritten text for each affected category in that single batch. Do not call \\\`save_memory\\\` again in a follow-up message for the same request after the user approved or skipped. +- Do not claim to remember something that is not in the injected memory unless you are saving it with \\\`save_memory\\\` in this turn. + +# Linear + +When the user asks about issues, projects, cycles, or tickets, use the Linear connection. Never answer from memory. + +- **Always call the tools first.** If a query returns nothing, broaden it (drop a filter, try \\\`list_teams\\\` / \\\`list_projects\\\`) before saying there are no results. +- **Never use \\\`state: "open"\\\`.** Linear has no such status — it returns an empty list without error. For non-done work, query with \\\`assignee: "me"\\\` (or the scope the user asked for) and exclude completed/canceled issues in your summary, or filter by real status types: \\\`backlog\\\`, \\\`unstarted\\\`, \\\`triage\\\`, \\\`started\\\`. +- **Scope from the user or the tools.** If they name a team, project, or label, pass that value to the tool. If the scope is unclear, use \\\`list_teams\\\` / \\\`list_projects\\\` or ask one short clarifying question — do not guess names. +- **"My issues" / "issues to check"** usually means issues assigned to the user that are not done yet. Say what you filtered on (assignee, team, status) in one line so the user can correct you. +- **Summarize briefly:** identifier, title, status, priority when useful. Offer to open one or take an action next. + +# GitHub + +When the user asks about repositories, pull requests, issues, commits, or CI, use the GitHub tools. Never answer from memory. + +- **Always call the tools first.** If a query returns nothing, broaden it (drop a filter, try \\\`searchRepositories\\\` / \\\`listPullRequests\\\`) before saying there are no results. +- **Scope from the user or the tools.** If they name an \\\`owner\\\` / \\\`repo\\\`, pass those values to the tool. If the scope is unclear, ask one short clarifying question — do not guess names. +- **Destructive writes need approval.** Merging PRs, closing issues, and editing files are gated — state briefly what you are about to do when proposing a write. +- **Summarize briefly:** repo, PR/issue number, title, state. Offer to open one or take an action next. + +# Format + +- Keep replies proportional to the question. +- Use markdown for code, lists, and structure when it aids clarity. +- Short paragraphs beat walls of text. + +# Greetings + +- In a new conversation, introduce yourself as \${agent.name} in one short line, then answer. +- Do not repeat your introduction on every message. + +# Boundaries + +- You are \${agent.name}. Never refer to yourself as "an AI language model" or a nameless assistant. +- You do not have real-time awareness of the world unless a tool provides it. +- Do not assume private context you have not been given.\`; +`, + ), + file( + "agent/lib/internal-api.ts", + "typescript", + `export function appOrigin() { + const configured = process.env.BETTER_AUTH_URL?.trim().replace(/\\/$/, ""); + if (configured) { + return configured; + } + + const vercelUrl = process.env.VERCEL_URL?.trim(); + if (vercelUrl) { + return \`https://\${vercelUrl}\`; + } + + return "http://localhost:3000"; +} + +export function internalHeaders() { + const secret = process.env.INTERNAL_API_SECRET?.trim(); + if (!secret) { + throw new Error("INTERNAL_API_SECRET is not configured"); + } + + return { + authorization: \`Bearer \${secret}\`, + "content-type": "application/json", + }; +} +`, + ), + file( + "agent/lib/memory-categories.ts", + "typescript", + `// Keep in sync with shared/types/memory.ts — duplicated here because Eve +// cannot resolve Nuxt's #shared alias at runtime. +export const MEMORY_CATEGORIES = [ + "work_context", + "personal_context", + "active_focus", + "instructions_preferences", + "project_history", +] as const; + +export type AgentMemoryCategory = typeof MEMORY_CATEGORIES[number]; +`, + ), + file( + "agent/lib/memory-internal.ts", + "typescript", + `import type { MemoryByCategory } from "../../shared/types/memory.js"; +import type { UserProfile } from "../../shared/types/profile.js"; +import { appOrigin, internalHeaders } from "./internal-api.js"; + +export interface UserContextPayload { + profile: UserProfile; + memory: MemoryByCategory; +} + +export async function fetchUserContext(userId: string): Promise { + const response = await fetch( + \`\${appOrigin()}/api/internal/memory?userId=\${encodeURIComponent(userId)}\`, + { headers: internalHeaders() }, + ); + + if (!response.ok) { + return undefined; + } + + return response.json() as Promise; +} + +export async function saveMemoryRemote(input: { + userId: string; + category: string; + content: string; +}) { + const response = await fetch(\`\${appOrigin()}/api/internal/memory\`, { + method: "POST", + headers: internalHeaders(), + body: JSON.stringify({ + userId: input.userId, + category: input.category, + content: input.content, + source: "agent", + }), + }); + + if (!response.ok) { + throw new Error("Failed to save memory"); + } + + return response.json() as Promise<{ saved: boolean }>; +} + +export function buildUserContextPrompt(context: UserContextPayload) { + const { profile, memory } = context; + const parts: string[] = []; + + parts.push("# About this user"); + if (profile.bio) { + parts.push(profile.bio); + } + parts.push(\`Timezone: \${profile.timezone}. Preferred language: \${profile.locale}.\`); + + const memorySections: string[] = []; + for (const [category, entries] of Object.entries(memory)) { + const entry = entries?.[0]; + if (!entry) continue; + const label = category.replace(/_/g, " ").replace(/\\b\\w/g, char => char.toUpperCase()); + memorySections.push(\`## \${label}\`); + memorySections.push(entry.content); + } + + if (memorySections.length) { + parts.push("# Memory"); + parts.push(memorySections.join("\\n\\n")); + } + + return parts.join("\\n\\n"); +} +`, + ), + file( + "agent/lib/phone-internal.ts", + "typescript", + `import type { PhoneLinkRecord } from "../../shared/types/phone-link.js"; +import { appOrigin, internalHeaders } from "./internal-api.js"; + +export async function fetchPhoneLinkForNumber(phoneNumber: string) { + const response = await fetch( + \`\${appOrigin()}/api/internal/phone/link?phoneNumber=\${encodeURIComponent(phoneNumber)}\`, + { headers: internalHeaders() }, + ); + + if (!response.ok) { + return undefined; + } + + const body = await response.json() as { link: PhoneLinkRecord | null }; + return body.link ?? undefined; +} +`, + ), + file( + "agent/lib/sendblue.ts", + "typescript", + `import { + createSendblueAdapter, + type SendblueAdapter, +} from "chat-adapter-sendblue"; +import type { SendblueMessagePayload } from "chat-adapter-sendblue"; + +const DEFAULT_ALLOWED_SERVICES = ["iMessage"] as const; +const WEBHOOK_SECRET_HEADER = "sb-signing-secret"; + +let adapter: SendblueAdapter | null = null; + +export function isSendblueConfigured() { + return Boolean( + process.env.SENDBLUE_API_KEY?.trim() + && process.env.SENDBLUE_API_SECRET?.trim() + && process.env.SENDBLUE_FROM_NUMBER?.trim(), + ); +} + +export function getSendblueAdapter() { + if (!adapter) { + if (!isSendblueConfigured()) { + throw new Error( + "Sendblue is not configured. Set SENDBLUE_API_KEY, SENDBLUE_API_SECRET, and SENDBLUE_FROM_NUMBER.", + ); + } + + adapter = createSendblueAdapter(); + } + + return adapter; +} + +export function verifySendblueWebhook(request: Request) { + const secret = process.env.SENDBLUE_WEBHOOK_SECRET?.trim(); + if (!secret) { + return true; + } + + const headerValue = request.headers.get(WEBHOOK_SECRET_HEADER); + return headerValue === secret; +} + +export function isSendblueServiceAllowed(service: string) { + const allowed = process.env.SENDBLUE_ALLOWED_SERVICES?.split(",") + .map((value) => value.trim()) + .filter(Boolean) ?? [...DEFAULT_ALLOWED_SERVICES]; + + return allowed.some((entry) => entry.toLowerCase() === service.toLowerCase()); +} + +export function isInboundSendblueMessage(body: unknown): body is SendblueMessagePayload { + if (!body || typeof body !== "object") { + return false; + } + + return "message_handle" in body && typeof body.message_handle === "string"; +} + +export function resolveSendblueLineNumber(payload: SendblueMessagePayload) { + const fromPayload = payload.sendblue_number?.trim() + || (payload.is_outbound ? payload.from_number : payload.to_number)?.trim(); + + if (fromPayload) { + return fromPayload; + } + + return process.env.SENDBLUE_FROM_NUMBER?.trim() ?? ""; +} + +export function threadIdFromPayload( + payload: SendblueMessagePayload, + sendblue: SendblueAdapter, +) { + const fromNumber = resolveSendblueLineNumber(payload); + + if (payload.group_id?.length) { + return sendblue.encodeThreadId({ fromNumber, groupId: payload.group_id }); + } + + const contactNumber = payload.is_outbound ? payload.to_number : payload.from_number; + return sendblue.encodeThreadId({ fromNumber, contactNumber }); +} + +export function contactNumberFromPayload(payload: SendblueMessagePayload) { + return payload.is_outbound ? payload.to_number : payload.from_number; +} + +export function profileSettingsUrl() { + const origin = process.env.BETTER_AUTH_URL?.trim().replace(/\\/$/, ""); + if (origin) { + return \`\${origin}/settings/profile\`; + } + + return "Settings → Profile in the web app"; +} +`, + ), + file( + "agent/lib/slack-internal.ts", + "typescript", + `import type { SlackLinkRecord } from "../../shared/types/slack-link.js"; +import { appOrigin, internalHeaders } from "./internal-api.js"; + +export async function fetchSlackLinkForMember(teamId: string, userId: string) { + const response = await fetch( + \`\${appOrigin()}/api/internal/slack/link/member?teamId=\${encodeURIComponent(teamId)}&userId=\${encodeURIComponent(userId)}\`, + { headers: internalHeaders() }, + ); + + if (!response.ok) { + return undefined; + } + + const body = await response.json() as { link: SlackLinkRecord | null }; + return body.link ?? undefined; +} + +export async function consumeSlackLinkCodeRemote(input: { + code: string; + slackTeamId: string; + slackUserId: string; + slackUserName?: string; + slackDisplayName?: string; + slackEmail?: string; +}) { + const response = await fetch(\`\${appOrigin()}/api/internal/slack/link/consume\`, { + method: "POST", + headers: internalHeaders(), + body: JSON.stringify(input), + }); + + if (!response.ok) { + return { ok: false as const, reason: "invalid" as const }; + } + + return response.json() as Promise< + | { ok: true; appUserId: string } + | { ok: false; reason: "invalid" | "expired" } + >; +} + +export function parseSlackLinkCommand(text: string) { + const match = text.match(/\\blink\\s+([A-Z0-9]{6})\\b/i); + return match?.[1]?.toUpperCase(); +} +`, + ), + file( + "agent/skills/daily-summary.md", + "markdown", + `Use when the user asks for a daily summary, morning briefing, or "summarize my day". + +Steps: +1. Load the user's active focus from injected memory when available. +2. Use the Linear connection to list issues assigned to the user that are not done (backlog, unstarted, triage, started). Never use \`state: "open"\`. +3. Produce a concise briefing with three sections: + - **Today** — top priorities and active focus + - **Linear** — assigned issues grouped by status (identifier, title, status) + - **Suggested next** — one concrete next action + +Keep it scannable. Match the user's language. +`, + ), + file( + "agent/tools/github.ts", + "typescript", + `import { buildEveToolMap } from "@github-tools/sdk/eve"; +import { getToken, UserAuthorizationRequiredError } from "@vercel/connect"; +import { defineDynamic } from "eve/tools"; +import { CONNECT_USER_ISSUER, GITHUB_CONNECTOR } from "../../shared/connect.js"; + +export default defineDynamic({ + events: { + "session.started": async (_event, ctx) => { + const auth = ctx.session.auth.current; + const userId = auth?.principalId; + if (!userId || userId.startsWith("eve:")) { + return {}; + } + + try { + const token = await getToken(GITHUB_CONNECTOR, { + subject: { + type: "user", + id: userId, + issuer: auth.issuer ?? auth.authenticator ?? CONNECT_USER_ISSUER, + }, + scopes: ["repo"], + }); + return buildEveToolMap({ preset: "maintainer", token }); + } + catch (error) { + if (error instanceof UserAuthorizationRequiredError) { + return {}; + } + throw error; + } + }, + }, +}); +`, + ), + file( + "agent/tools/save_memory.ts", + "typescript", + `import { defineTool } from "eve/tools"; +import { always } from "eve/tools/approval"; +import { z } from "zod"; +import { MEMORY_CATEGORIES } from "../lib/memory-categories.js"; +import { saveMemoryRemote } from "../lib/memory-internal.js"; + +const updateSchema = z.object({ + category: z.enum(MEMORY_CATEGORIES).describe("Memory category to update"), + content: z.string().min(1).describe("Full replacement prose for this category (not a partial delta)"), +}); + +export default defineTool({ + description: + "Propose saving memory updates. Requires one user approval for the whole batch. When several categories change, include every update in a single call — never parallel save_memory calls.", + inputSchema: z.object({ + reason: z.string().min(1).describe("Brief explanation of why these updates are worth remembering"), + updates: z.array(updateSchema).min(1).max(5).describe("Category updates to save together"), + }), + approval: always(), + async execute({ updates }, ctx) { + const userId = ctx.session.auth.current?.principalId; + if (!userId) { + throw new Error("Cannot save memory without an authenticated user"); + } + + const results = []; + for (const update of updates) { + const result = await saveMemoryRemote({ + userId, + category: update.category, + content: update.content, + }); + results.push({ + category: update.category, + saved: result.saved, + }); + } + + return { results }; + }, +}); +`, + ), + file( + "agent/tools/weather.ts", + "typescript", + `import { defineTool } from "eve/tools"; +import { z } from "zod"; + +interface OpenMeteoResponse { + current?: { + temperature_2m?: number; + relative_humidity_2m?: number; + wind_speed_10m?: number; + weather_code?: number; + }; + daily?: { + time?: string[]; + temperature_2m_max?: number[]; + temperature_2m_min?: number[]; + weather_code?: number[]; + }; +} + +const WEATHER_CODES: Record = { + 0: { text: "Clear sky", icon: "sun" }, + 1: { text: "Mainly clear", icon: "sun" }, + 2: { text: "Partly cloudy", icon: "cloud-sun" }, + 3: { text: "Overcast", icon: "cloud" }, + 45: { text: "Foggy", icon: "cloud-fog" }, + 48: { text: "Foggy", icon: "cloud-fog" }, + 51: { text: "Light drizzle", icon: "cloud-drizzle" }, + 61: { text: "Rain", icon: "cloud-rain" }, + 71: { text: "Snow", icon: "cloud-snow" }, + 80: { text: "Rain showers", icon: "cloud-rain" }, + 95: { text: "Thunderstorm", icon: "cloud-lightning" }, +}; + +function conditionFromCode(code?: number) { + return WEATHER_CODES[code ?? 0] ?? { text: "Unknown", icon: "cloud" }; +} + +async function geocode(location: string) { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search"); + url.searchParams.set("name", location); + url.searchParams.set("count", "1"); + url.searchParams.set("language", "en"); + url.searchParams.set("format", "json"); + + const response = await fetch(url); + if (!response.ok) { + throw new Error("Geocoding failed"); + } + + const data = await response.json() as { results?: Array<{ name: string; latitude: number; longitude: number }> }; + const result = data.results?.[0]; + if (!result) { + throw new Error(\`Could not find location: \${location}\`); + } + + return result; +} + +export default defineTool({ + description: "Get current weather and a short forecast for a location", + inputSchema: z.object({ + location: z.string().describe("City or place name"), + }), + outputSchema: z.object({ + location: z.string(), + temperature: z.number(), + temperatureHigh: z.number(), + temperatureLow: z.number(), + condition: z.object({ + text: z.string(), + icon: z.string(), + }), + humidity: z.number(), + windSpeed: z.number(), + dailyForecast: z.array( + z.object({ + day: z.string(), + high: z.number(), + low: z.number(), + condition: z.object({ + text: z.string(), + icon: z.string(), + }), + }), + ), + }), + async execute({ location }) { + const place = await geocode(location); + const url = new URL("https://api.open-meteo.com/v1/forecast"); + url.searchParams.set("latitude", String(place.latitude)); + url.searchParams.set("longitude", String(place.longitude)); + url.searchParams.set("current", "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"); + url.searchParams.set("daily", "weather_code,temperature_2m_max,temperature_2m_min"); + url.searchParams.set("timezone", "auto"); + url.searchParams.set("forecast_days", "5"); + + const response = await fetch(url); + if (!response.ok) { + throw new Error("Weather lookup failed"); + } + + const data = await response.json() as OpenMeteoResponse; + const currentCode = data.current?.weather_code; + const currentCondition = conditionFromCode(currentCode); + const label = place.name; + + const dailyForecast = (data.daily?.time ?? []).slice(0, 5).map((day, index) => { + const code = data.daily?.weather_code?.[index]; + const condition = conditionFromCode(code); + return { + day, + high: Math.round(data.daily?.temperature_2m_max?.[index] ?? 0), + low: Math.round(data.daily?.temperature_2m_min?.[index] ?? 0), + condition, + }; + }); + + const today = dailyForecast[0]; + + return { + location: label, + temperature: Math.round(data.current?.temperature_2m ?? today?.high ?? 0), + temperatureHigh: today?.high ?? Math.round(data.current?.temperature_2m ?? 0), + temperatureLow: today?.low ?? Math.round(data.current?.temperature_2m ?? 0), + condition: currentCondition, + humidity: Math.round(data.current?.relative_humidity_2m ?? 0), + windSpeed: Math.round(data.current?.wind_speed_10m ?? 0), + dailyForecast, + }; + }, +}); `, ), ],