- What is the weather in Tokyo?
+ What can you do?
- What is assistant-ui?
+ Label and archive newsletters
+
+
+
+
+ Label urgent emails
+
+
+
+
+ Label emails needing a reply
From b4343dc6467465b5fa39b2b977b5b9a0d6ba152b Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Fri, 21 Mar 2025 01:52:50 +0200
Subject: [PATCH 03/11] create/list rules via chat
---
apps/web/app/api/chat/route.ts | 108 +++++++++++++++++++++++++++++----
1 file changed, 97 insertions(+), 11 deletions(-)
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index 3428691f5..675fe352e 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -1,25 +1,111 @@
import { anthropic } from "@ai-sdk/anthropic";
-import { jsonSchema, streamText } from "ai";
+import { streamText, tool } from "ai";
+import { z } from "zod";
+import { createScopedLogger } from "@/utils/logger";
+import {
+ createRuleSchema,
+ type CreateRuleSchemaWithCategories,
+} from "@/utils/ai/rule/create-rule-schema";
+import { getUserCategoriesForNames } from "@/utils/category.server";
+import prisma from "@/utils/prisma";
+import { createRule } from "@/utils/rule/rule";
+import { auth } from "@/app/api/auth/[...nextauth]/auth";
+
+const logger = createScopedLogger("chat");
-export const runtime = "edge";
export const maxDuration = 30;
export async function POST(req: Request) {
- const { messages, system, tools } = await req.json();
+ const session = await auth();
+ if (!session?.user.id) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+
+ const userId = session.user.id;
+
+ const { messages } = await req.json();
+
+ const system =
+ "You are an assistant that helps create rules to manage a user's inbox.";
const result = streamText({
model: anthropic("claude-3-5-sonnet-20240620"),
messages,
- // forward system prompt and tools from the frontend
system,
- tools: Object.fromEntries(
- Object.entries<{ parameters: unknown }>(tools).map(([name, tool]) => [
- name,
- {
- parameters: jsonSchema(tool.parameters!),
+ tools: {
+ create_rule: tool({
+ description: "Create a new rule",
+ // parameters: categories
+ // ? getCreateRuleSchemaWithCategories(
+ // categories.map((c) => c.name) as [string, ...string[]],
+ // )
+ // : createRuleSchema,
+ parameters: createRuleSchema,
+ execute: async ({ name, condition, actions }) => {
+ logger.info("Create Rule", { name, condition, actions });
+ // trackToolCall("create_rule", user.email);
+
+ const conditions =
+ condition as CreateRuleSchemaWithCategories["condition"];
+
+ try {
+ const categoryIds = await getUserCategoriesForNames(
+ userId,
+ conditions.categories?.categoryFilters || [],
+ );
+
+ const rule = await createRule({
+ result: { name, condition, actions },
+ userId,
+ categoryIds,
+ });
+
+ if ("error" in rule) {
+ logger.error("Error while creating rule", {
+ // ...loggerOptions,
+ error: rule.error,
+ });
+
+ return {
+ error: "Failed to create rule",
+ message: rule.error,
+ };
+ }
+
+ // createdRules.set(rule.id, rule);
+
+ return { success: true };
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : String(error);
+
+ logger.error("Failed to create rule", {
+ // ...loggerOptions,
+ error: message,
+ });
+
+ return {
+ error: "Failed to create rule",
+ message,
+ };
+ }
+ },
+ }),
+ list_rules: tool({
+ description: "List all existing rules for the user",
+ parameters: z.object({}),
+ execute: async () => {
+ // trackToolCall("list_rules", user.email);
+ // return userRules;
+
+ const rules = await prisma.rule.findMany({
+ where: { userId },
+ });
+
+ return rules;
},
- ]),
- ),
+ }),
+ },
});
return result.toDataStreamResponse();
From 5dcc6bd73300dcf03267942873607532609ce66e Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Fri, 21 Mar 2025 12:09:57 +0200
Subject: [PATCH 04/11] Update prompt
---
apps/web/__tests__/ai-choose-args.test.ts | 3 +
apps/web/__tests__/ai-prompt-to-rules.test.ts | 2 +-
apps/web/app/api/chat/route.ts | 340 +++++++++++++++++-
3 files changed, 338 insertions(+), 7 deletions(-)
diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts
index 7a2cb093c..b8280791c 100644
--- a/apps/web/__tests__/ai-choose-args.test.ts
+++ b/apps/web/__tests__/ai-choose-args.test.ts
@@ -191,6 +191,8 @@ function getRule(
conditionalOperator: LogicalOperator.AND,
type: null,
trackReplies: null,
+ draftReplies: null,
+ draftRepliesInstructions: null,
};
}
@@ -200,6 +202,7 @@ function getEmail({
content = "content",
}: { from?: string; subject?: string; content?: string } = {}) {
return {
+ id: "id",
from,
subject,
content,
diff --git a/apps/web/__tests__/ai-prompt-to-rules.test.ts b/apps/web/__tests__/ai-prompt-to-rules.test.ts
index 65e51d2c7..665eba645 100644
--- a/apps/web/__tests__/ai-prompt-to-rules.test.ts
+++ b/apps/web/__tests__/ai-prompt-to-rules.test.ts
@@ -329,6 +329,6 @@ describe.skipIf(!isAiTest)("aiPromptToRules", () => {
const replyAction = result[0].actions.find(
(a) => a.type === ActionType.REPLY,
);
- expect(replyAction?.content).toContain("{{firstName}}");
+ expect(replyAction?.fields?.content).toContain("{{firstName}}");
}, 15_000);
});
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index 675fe352e..dcad05bfe 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -10,10 +10,11 @@ import { getUserCategoriesForNames } from "@/utils/category.server";
import prisma from "@/utils/prisma";
import { createRule } from "@/utils/rule/rule";
import { auth } from "@/app/api/auth/[...nextauth]/auth";
+import { ActionType, ColdEmailSetting, LogicalOperator } from "@prisma/client";
const logger = createScopedLogger("chat");
-export const maxDuration = 30;
+export const maxDuration = 120;
export async function POST(req: Request) {
const session = await auth();
@@ -25,8 +26,245 @@ export async function POST(req: Request) {
const { messages } = await req.json();
- const system =
- "You are an assistant that helps create rules to manage a user's inbox.";
+ const system = `You are an assistant that helps create and update rules to manage a user's inbox.
+
+You can't perform any actions, you can only adjust their rules.
+
+A rule is comprised of:
+1. A condition
+2. A set of actions
+
+A condition can be:
+1. AI instructions
+2. Static
+
+An action can be:
+1. Archive
+2. Label
+3. Draft a reply
+4. Send an email
+5. Forward
+6. Mark as read
+7. Mark spam
+8. Call a webhook
+
+You can use {{variables}} in the fields to insert AI generated content. For example:
+"Hi {{name}}, {{write a friendly reply}}, Best regards, Alice"
+
+Rule matching logic:
+- All static conditions (from, to, subject, body) use AND logic - meaning all static conditions must match
+- Top level conditions (AI instructions, static, category) can use either AND or OR logic, controlled by the "conditionalOperator" setting
+
+Best practices:
+- For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses
+- IMPORTANT: do not create new rules unless absolutely necessary. Avoid duplicate rules, so make sure to check if the rule already exists.
+- You can use multiple conditions in a rule, but aim for simplicity.
+- When creating rules, in most cases, you should use the "aiInstructions" and sometimes you will use other fields in addition.
+- If a rule can be handled fully with static conditions, do so, but this is rarely possible.
+
+Always explain the changes you made.
+Use simple language and avoid jargon in your reply.
+If you are unable to fix the rule, say so.
+
+You can set general infomation about the user too that will be passed as context when the AI is processing emails.
+You can enable the cold email blocker by setting the "coldEmailBlocker" setting to true.
+You can enable the reply zero setting by setting the "replyZero" setting to true. Reply Zero is a feature that tracks replies for users.
+
+Examples:
+
+
+
+
+ When I get a newsletter, archive it and label it as "Newsletter"
+
+
+
+
+
+
+ I run a marketing agency and use this email address for cold outreach.
+ If someone shows interest, label it "Interested".
+ If someone says they're interested in learning more, send them my Cal link (cal.com/alice).
+ If they ask for more info, send them my deck (https://drive.google.com/alice-deck.pdf).
+ If they're not interested, label it as "Not interested" and archive it.
+ If you don't know how to respond, label it as "Needs review".
+
+
+
+
+
+
+ Set a rule to archive emails older than 30 days.
+
+
+
+
+
+
+ Create some good default rules for me.
+
+
+
+
+
+
+ I don't need to reply to emails from GitHub, stop labelling them as "To reply".
+
+
+
+`;
const result = streamText({
model: anthropic("claude-3-5-sonnet-20240620"),
@@ -91,6 +329,65 @@ export async function POST(req: Request) {
}
},
}),
+ update_rule: tool({
+ description: "Update an existing rule",
+ parameters: z.object({
+ ruleName: z.string().describe("The name of the rule to update"),
+ condition: z
+ .object({
+ aiInstructions: z.string(),
+ static: z.object({
+ from: z.string(),
+ to: z.string(),
+ subject: z.string(),
+ body: z.string(),
+ }),
+ conditionalOperator: z.enum([
+ LogicalOperator.AND,
+ LogicalOperator.OR,
+ ]),
+ })
+ .optional(),
+ actions: z.array(
+ z
+ .object({
+ type: z.enum([
+ ActionType.ARCHIVE,
+ ActionType.LABEL,
+ ActionType.REPLY,
+ ActionType.SEND_EMAIL,
+ ActionType.FORWARD,
+ ActionType.MARK_READ,
+ ActionType.MARK_SPAM,
+ ActionType.CALL_WEBHOOK,
+ ]),
+ fields: z.object({
+ label: z.string().optional(),
+ content: z.string().optional(),
+ webhookUrl: z.string().optional(),
+ }),
+ })
+ .optional(),
+ ),
+ learnedPatterns: z
+ .array(
+ z.object({
+ include: z.object({
+ from: z.string(),
+ subject: z.string(),
+ }),
+ exclude: z.object({
+ from: z.string(),
+ subject: z.string(),
+ }),
+ }),
+ )
+ .optional(),
+ }),
+ execute: async ({ ruleName, condition, actions, learnedPatterns }) => {
+ return { success: true };
+ },
+ }),
list_rules: tool({
description: "List all existing rules for the user",
parameters: z.object({}),
@@ -98,13 +395,44 @@ export async function POST(req: Request) {
// trackToolCall("list_rules", user.email);
// return userRules;
- const rules = await prisma.rule.findMany({
- where: { userId },
- });
+ const rules = await prisma.rule.findMany({ where: { userId } });
return rules;
},
}),
+ update_about: tool({
+ description: "Update the user's about information",
+ parameters: z.object({
+ about: z.string(),
+ }),
+ execute: async ({ about }) => {
+ return { success: true };
+ },
+ }),
+ enable_cold_email_blocker: tool({
+ description: "Enable the cold email blocker",
+ parameters: z.object({
+ action: z.enum([
+ ColdEmailSetting.DISABLED,
+ ColdEmailSetting.LABEL,
+ ColdEmailSetting.ARCHIVE_AND_LABEL,
+ ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL,
+ ]),
+ }),
+ execute: async ({ action }) => {
+ return { success: true };
+ },
+ }),
+ enable_reply_zero: tool({
+ description: "Enable the reply zero feature",
+ parameters: z.object({
+ enabled: z.boolean(),
+ draft_replies: z.boolean(),
+ }),
+ execute: async ({ enabled, draft_replies }) => {
+ return { success: true };
+ },
+ }),
},
});
From 122ef7dca17338cbad8fccfef6b0e1574c48ad8d Mon Sep 17 00:00:00 2001
From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com>
Date: Sat, 22 Mar 2025 22:25:22 +0200
Subject: [PATCH 05/11] Add vercel chat instead of ui assistant
---
apps/web/app/(app)/automation/Rules.tsx | 12 +-
apps/web/app/(app)/automation/chat/page.tsx | 12 +-
apps/web/app/api/chat/route.ts | 166 +++++++------
apps/web/app/assistant.tsx | 9 +-
apps/web/components/assistant-chat/chat.tsx | 101 ++++++++
.../assistant-chat/data-stream-handler.tsx | 31 +++
apps/web/components/assistant-chat/icons.tsx | 168 ++++++++++++++
.../components/assistant-chat/markdown.tsx | 108 +++++++++
.../assistant-chat/message-editor.tsx | 110 +++++++++
.../assistant-chat/message-reasoning.tsx | 79 +++++++
.../web/components/assistant-chat/message.tsx | 172 ++++++++++++++
.../components/assistant-chat/messages.tsx | 64 +++++
.../assistant-chat/multimodal-input.tsx | 218 ++++++++++++++++++
.../components/assistant-chat/overview.tsx | 24 ++
.../assistant-chat/submit-button.tsx | 36 +++
.../assistant-chat/suggested-actions.tsx | 74 ++++++
apps/web/components/assistant-chat/tools.tsx | 190 +++++++++++++++
.../assistant-chat/use-scroll-to-bottom.ts | 31 +++
apps/web/components/assistant-ui/thread.tsx | 4 +-
apps/web/components/ui/textarea.tsx | 22 ++
apps/web/package.json | 3 +
apps/web/providers/SWRProvider.tsx | 1 +
pnpm-lock.yaml | 60 ++++-
23 files changed, 1603 insertions(+), 92 deletions(-)
create mode 100644 apps/web/components/assistant-chat/chat.tsx
create mode 100644 apps/web/components/assistant-chat/data-stream-handler.tsx
create mode 100644 apps/web/components/assistant-chat/icons.tsx
create mode 100644 apps/web/components/assistant-chat/markdown.tsx
create mode 100644 apps/web/components/assistant-chat/message-editor.tsx
create mode 100644 apps/web/components/assistant-chat/message-reasoning.tsx
create mode 100644 apps/web/components/assistant-chat/message.tsx
create mode 100644 apps/web/components/assistant-chat/messages.tsx
create mode 100644 apps/web/components/assistant-chat/multimodal-input.tsx
create mode 100644 apps/web/components/assistant-chat/overview.tsx
create mode 100644 apps/web/components/assistant-chat/submit-button.tsx
create mode 100644 apps/web/components/assistant-chat/suggested-actions.tsx
create mode 100644 apps/web/components/assistant-chat/tools.tsx
create mode 100644 apps/web/components/assistant-chat/use-scroll-to-bottom.ts
create mode 100644 apps/web/components/ui/textarea.tsx
diff --git a/apps/web/app/(app)/automation/Rules.tsx b/apps/web/app/(app)/automation/Rules.tsx
index 2b81d74fc..e0a89cc01 100644
--- a/apps/web/app/(app)/automation/Rules.tsx
+++ b/apps/web/app/(app)/automation/Rules.tsx
@@ -50,6 +50,7 @@ import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/utils";
import { type RiskLevel, getRiskLevel } from "@/utils/risk";
import { useRules } from "@/hooks/useRules";
+import type { ActionType } from "@prisma/client";
export function Rules() {
const { data, isLoading, error, mutate } = useRules();
@@ -151,7 +152,7 @@ export function Rules() {
{actions?.map((action) => {
diff --git a/apps/web/app/(app)/automation/chat/page.tsx b/apps/web/app/(app)/automation/chat/page.tsx
index 40a0b42ba..7b7a18479 100644
--- a/apps/web/app/(app)/automation/chat/page.tsx
+++ b/apps/web/app/(app)/automation/chat/page.tsx
@@ -1,5 +1,13 @@
-import { Assistant } from "@/app/assistant";
+// import { Assistant } from "@/app/assistant";
+import { Chat } from "@/components/assistant-chat/chat";
+import { DataStreamHandler } from "@/components/assistant-chat/data-stream-handler";
export default function AssistantChatPage() {
- return ;
+ // return ;
+ return (
+ <>
+
+
+ >
+ );
}
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index dcad05bfe..528464f6c 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -16,6 +16,84 @@ const logger = createScopedLogger("chat");
export const maxDuration = 120;
+// schemas
+export type CreateRuleSchema = z.infer;
+
+const updateRuleSchema = z.object({
+ ruleName: z.string().describe("The name of the rule to update"),
+ condition: z
+ .object({
+ aiInstructions: z.string(),
+ static: z.object({
+ from: z.string(),
+ to: z.string(),
+ subject: z.string(),
+ body: z.string(),
+ }),
+ conditionalOperator: z.enum([LogicalOperator.AND, LogicalOperator.OR]),
+ })
+ .optional(),
+ actions: z.array(
+ z
+ .object({
+ type: z.enum([
+ ActionType.ARCHIVE,
+ ActionType.LABEL,
+ ActionType.REPLY,
+ ActionType.SEND_EMAIL,
+ ActionType.FORWARD,
+ ActionType.MARK_READ,
+ ActionType.MARK_SPAM,
+ ActionType.CALL_WEBHOOK,
+ ]),
+ fields: z.object({
+ label: z.string().optional(),
+ content: z.string().optional(),
+ webhookUrl: z.string().optional(),
+ }),
+ })
+ .optional(),
+ ),
+ learnedPatterns: z
+ .array(
+ z.object({
+ include: z.object({
+ from: z.string(),
+ subject: z.string(),
+ }),
+ exclude: z.object({
+ from: z.string(),
+ subject: z.string(),
+ }),
+ }),
+ )
+ .optional(),
+});
+export type UpdateRuleSchema = z.infer;
+
+const updateAboutSchema = z.object({
+ about: z.string(),
+});
+export type UpdateAboutSchema = z.infer;
+
+const enableColdEmailBlockerSchema = z.object({
+ action: z.enum([
+ ColdEmailSetting.DISABLED,
+ ColdEmailSetting.LABEL,
+ ColdEmailSetting.ARCHIVE_AND_LABEL,
+ ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL,
+ ]),
+});
+export type EnableColdEmailBlockerSchema = z.infer<
+ typeof enableColdEmailBlockerSchema
+>;
+
+const enableReplyZeroSchema = z.object({
+ enabled: z.boolean(),
+ draft_replies: z.boolean(),
+});
+export type EnableReplyZeroSchema = z.infer;
+
export async function POST(req: Request) {
const session = await auth();
if (!session?.user.id) {
@@ -26,7 +104,7 @@ export async function POST(req: Request) {
const { messages } = await req.json();
- const system = `You are an assistant that helps create and update rules to manage a user's inbox.
+ const system = `You are an assistant that helps create and update rules to manage a user's inbox. Our platform is called Inbox Zero.
You can't perform any actions, you can only adjust their rules.
@@ -53,7 +131,7 @@ You can use {{variables}} in the fields to insert AI generated content. For exam
Rule matching logic:
- All static conditions (from, to, subject, body) use AND logic - meaning all static conditions must match
-- Top level conditions (AI instructions, static, category) can use either AND or OR logic, controlled by the "conditionalOperator" setting
+- Top level conditions (AI instructions, static) can use either AND or OR logic, controlled by the "conditionalOperator" setting
Best practices:
- For static conditions, use email patterns (e.g., '@company.com') when matching multiple addresses
@@ -67,8 +145,14 @@ Use simple language and avoid jargon in your reply.
If you are unable to fix the rule, say so.
You can set general infomation about the user too that will be passed as context when the AI is processing emails.
-You can enable the cold email blocker by setting the "coldEmailBlocker" setting to true.
-You can enable the reply zero setting by setting the "replyZero" setting to true. Reply Zero is a feature that tracks replies for users.
+Reply Zero is a feature that labels emails that need a reply "To Reply". And labels emails that are awaiting a response "Awaiting". The also is also able to see these in a minimalist UI within Inbox Zero which only shows these features.
+Don't tell the user which tools you're using. The tools you use will be displayed in the UI anyway.
+Don't use placeholders in rules you create. For example, don't use @company.com. Use the user's actual company email address. And if you don't know some information you need, you can ask the user.
+
+Learned patterns:
+- Learned patterns override the conditional logic for a rule.
+- This avoids us having to use AI to process rules.
+- There's some similarity to static rules, but you can only use one static condition for a rule. But you can use multiple learned patterns. And over time the list of learned patterns will grow.
Examples:
@@ -233,7 +317,7 @@ Examples:
{
"name": "Team",
- "condition": { "aiInstructions": "Team emails" },
+ "condition": { "static": { "from": "@company.com" } },
"actions": [
{ "type": "label", "fields": { "label": "Team" } }
]
@@ -331,59 +415,7 @@ Examples:
}),
update_rule: tool({
description: "Update an existing rule",
- parameters: z.object({
- ruleName: z.string().describe("The name of the rule to update"),
- condition: z
- .object({
- aiInstructions: z.string(),
- static: z.object({
- from: z.string(),
- to: z.string(),
- subject: z.string(),
- body: z.string(),
- }),
- conditionalOperator: z.enum([
- LogicalOperator.AND,
- LogicalOperator.OR,
- ]),
- })
- .optional(),
- actions: z.array(
- z
- .object({
- type: z.enum([
- ActionType.ARCHIVE,
- ActionType.LABEL,
- ActionType.REPLY,
- ActionType.SEND_EMAIL,
- ActionType.FORWARD,
- ActionType.MARK_READ,
- ActionType.MARK_SPAM,
- ActionType.CALL_WEBHOOK,
- ]),
- fields: z.object({
- label: z.string().optional(),
- content: z.string().optional(),
- webhookUrl: z.string().optional(),
- }),
- })
- .optional(),
- ),
- learnedPatterns: z
- .array(
- z.object({
- include: z.object({
- from: z.string(),
- subject: z.string(),
- }),
- exclude: z.object({
- from: z.string(),
- subject: z.string(),
- }),
- }),
- )
- .optional(),
- }),
+ parameters: updateRuleSchema,
execute: async ({ ruleName, condition, actions, learnedPatterns }) => {
return { success: true };
},
@@ -402,33 +434,21 @@ Examples:
}),
update_about: tool({
description: "Update the user's about information",
- parameters: z.object({
- about: z.string(),
- }),
+ parameters: updateAboutSchema,
execute: async ({ about }) => {
return { success: true };
},
}),
enable_cold_email_blocker: tool({
description: "Enable the cold email blocker",
- parameters: z.object({
- action: z.enum([
- ColdEmailSetting.DISABLED,
- ColdEmailSetting.LABEL,
- ColdEmailSetting.ARCHIVE_AND_LABEL,
- ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL,
- ]),
- }),
+ parameters: enableColdEmailBlockerSchema,
execute: async ({ action }) => {
return { success: true };
},
}),
enable_reply_zero: tool({
description: "Enable the reply zero feature",
- parameters: z.object({
- enabled: z.boolean(),
- draft_replies: z.boolean(),
- }),
+ parameters: enableReplyZeroSchema,
execute: async ({ enabled, draft_replies }) => {
return { success: true };
},
diff --git a/apps/web/app/assistant.tsx b/apps/web/app/assistant.tsx
index 43d1e3d94..436c0e89e 100644
--- a/apps/web/app/assistant.tsx
+++ b/apps/web/app/assistant.tsx
@@ -3,19 +3,22 @@
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
import { Thread } from "@/components/assistant-ui/thread";
-import { Rules } from "@/app/(app)/automation/Rules";
export const Assistant = () => {
const runtime = useChatRuntime({ api: "/api/chat" });
return (
-