Skip to content
Merged
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**An open-source agent that does real work in your tools — and can't be talked into doing the wrong thing.**

SynapseAI works in the services you already use (Gmail and Google Calendar built in; GitHub, Linear, Notion, Jira, Sentry, or any [MCP](https://modelcontextprotocol.io) server), takes multi-step actions on your behalf, and asks before anything that writes, sends or deletes. It doesn't wait to be asked: automations start work on a schedule, when an email arrives, or when another app calls a webhook. Approvals are enforced in code, not in the prompt; once the agent has read untrusted content such as an email or a web page, every outward action needs your sign-off. Self-host it with one command, bring your own model keys, and see exactly what it did and what it cost.
SynapseAI works in the services you already use (Gmail, Google Calendar, Drive, Contacts and Slack built in; GitHub, Linear, Notion, Jira, Sentry, or any [MCP](https://modelcontextprotocol.io) server), takes multi-step actions on your behalf, and asks before anything that writes, sends or deletes. It doesn't wait to be asked: automations start work on a schedule, when an email arrives, or when another app calls a webhook. Approvals are enforced in code, not in the prompt; once the agent has read untrusted content such as an email or a web page, every outward action needs your sign-off. Self-host it with one command, bring your own model keys, and see exactly what it did and what it cost.

![A task waiting for approval: the agent drafted an email and shows it as an editable card, with the activity timeline on the right](docs/screenshots/task.png)

Expand All @@ -26,7 +26,8 @@ Agent products that act in your tools share three problems: approvals that live
## Features

- Chat with live streaming (reasoning, tool calls, results) that resumes after reconnects
- Built-in Gmail and Google Calendar: search, read threads, draft, reply in-thread, label and archive, book and move meetings, answer invitations ([setup](docs/google.md))
- Built-in Gmail and Google Calendar: search, read threads, draft, reply in-thread, label and archive, find free time, book and move meetings, answer invitations ([setup](docs/google.md))
- Built-in Google Drive and Contacts: search Drive and read Docs, Sheets, Slides and PDFs, create Google Docs (with approval), and look people up by name so "email Sam" just works
- Automations triggered by a cron schedule, new Gmail messages matching a search, or a secret webhook URL
- MCP connections over Streamable HTTP with OAuth 2.1 + PKCE, or bearer tokens; presets for GitHub, Linear, Notion, Atlassian, Sentry
- Approval inbox, editable approvals, deny-with-reason, cancel now / after this step
Expand All @@ -35,6 +36,7 @@ Agent products that act in your tools share three problems: approvals that live
- Approvals that learn: after you approve the same kind of action unchanged a few times, Synapse offers a visible, removable rule ("emails where every address is @acme.com"). Tasks that read outside content still ask
- Approve from your phone: install Synapse as an app and get push notifications with Approve and Deny buttons (the undo window still applies), or opt in to email alerts ([setup](docs/notifications.md))
- Approve from Slack: approvals arrive as a DM with Approve and Deny buttons, and the message updates wherever you decide ([setup](docs/slack.md))
- Built-in Slack tools that act as you: search messages, read channels and threads, find people, and post in a channel, a DM or a thread. Posting asks for approval by default, and a channel that only showed up in a message the agent read is flagged as new ([setup](docs/slack.md))
- Undo window: approved sends and deletes wait a few seconds (default 10, configurable) with an Undo button
- Team workspaces: one set of rules for everyone in the team (the strictest answer wins, a team block can't be overridden), risky actions routed to an admin for sign-off, invite links, and a CSV audit export. Tasks, connections and memory stay personal ([how it works](docs/teams.md))
- Skills in the open Agent Skills format: six built in (inbox triage, meeting prep, follow-ups, weekly calendar review, polite declines, status updates), plus your own, written on the Skills page, imported as a `SKILL.md`, or saved from a chat. The agent sees one line per skill and loads the rest only when a task needs it; every use is recorded with the skill's version. Skills are instructions, never permissions ([how they work](docs/skills.md))
Expand Down Expand Up @@ -94,7 +96,7 @@ The server is stateless apart from Postgres. Run the API and workers separately
|---|---|
| `packages/core` | Host-agnostic agent runtime: run loop, approval state machine, policy, compaction, model fallback, client event reducer. No database or HTTP. |
| `packages/db` | Drizzle schema and migrations, Postgres store for the runtime, run leasing, ordered event log, secret encryption. |
| `packages/tools` | Gmail and Calendar, MCP client with OAuth, SSRF guard, web search/fetch, knowledge base, memory. |
| `packages/tools` | Gmail, Calendar, Drive and Contacts, Slack, MCP client with OAuth, SSRF guard, web search/fetch, knowledge base, memory. |
| `packages/evals` | Scenario harness (task + safety) with simulated users; `bun run evals`. |
| `apps/server` | HTTP API, auth, SSE, worker. |
| `apps/web` | Next.js UI. |
Expand Down
173 changes: 173 additions & 0 deletions apps/server/src/google-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { type ScriptedStep, scriptedModel } from "@synapse/core/testing";
import { schema } from "@synapse/db";
import { fakeGoogle, LEGACY_GOOGLE_SCOPE } from "@synapse/tools/testing";
import { and, eq } from "drizzle-orm";
import { createApp } from "./app";
import { loadConfig } from "./config";
import { createServices, type Services } from "./services";
import { Worker } from "./worker";

const ORIGIN = "http://localhost:3000";
const google = fakeGoogle({ email: "ada@example.com" });
let services: Services;
let app: ReturnType<typeof createApp>;
let worker: Worker;
let model = scriptedModel([]);
const script = (steps: ScriptedStep[]) => {
model = scriptedModel(steps);
};

beforeAll(async () => {
services = await createServices(
loadConfig({
NODE_ENV: "test",
DATABASE_URL: "pglite://memory",
SYNAPSE_SECRET: "t".repeat(40),
PUBLIC_URL: ORIGIN,
GOOGLE_CLIENT_ID: "cid",
GOOGLE_CLIENT_SECRET: "secret",
}),
{ models: () => [model] },
);
services.config.google!.fetch = google.fetch;
app = createApp(services);
worker = new Worker(services, () => [model]);
});
afterAll(() => services.close());

async function signUp(email: string) {
const res = await app.request("/api/auth/sign-up/email", {
method: "POST",
headers: { "content-type": "application/json", origin: ORIGIN },
body: JSON.stringify({ email, password: "correct horse battery", name: email.split("@")[0] }),
});
expect(res.status).toBe(200);
const cookie = res.headers
.getSetCookie()
.map((c) => c.split(";")[0])
.join("; ");
return async (path: string, init: { method?: string; json?: unknown } = {}) => {
const r = await app.request(path, {
method: init.method ?? "GET",
headers: {
cookie,
origin: ORIGIN,
...(init.json !== undefined ? { "content-type": "application/json" } : {}),
},
...(init.json !== undefined ? { body: JSON.stringify(init.json) } : {}),
});
const text = await r.text();
// biome-ignore lint/suspicious/noExplicitAny: test responses are loosely typed JSON
return { status: r.status, json: text ? (JSON.parse(text) as any) : null };
};
}
type Call = Awaited<ReturnType<typeof signUp>>;

async function connectGoogle(me: Call, scope?: string) {
const before = google.state.grantedScope;
if (scope) google.state.grantedScope = scope;
const start = await me("/api/integrations/google/connect", { method: "POST", json: {} });
const state = new URL(start.json.url).searchParams.get("state")!;
await me(`/api/integrations/google/callback?code=abc&state=${encodeURIComponent(state)}`);
google.state.grantedScope = before;
}

async function toolCall(runId: string, id: string) {
const [row] = await services.database.db
.select()
.from(schema.toolCalls)
.where(and(eq(schema.toolCalls.runId, runId), eq(schema.toolCalls.id, id)));
return row!;
}

async function run(runId: string) {
const [row] = await services.database.db
.select()
.from(schema.runs)
.where(eq(schema.runs.id, runId));
return row!;
}

test("a grant from before Drive and Contacts keeps working and asks for a reconnect", async () => {
const ada = await signUp("legacy@gtools.test");
await connectGoogle(ada, LEGACY_GOOGLE_SCOPE);
const status = await ada("/api/integrations");
expect(status.json.google.connection.status).toBe("active");
expect(status.json.google.connection.missing.map((m: { label: string }) => m.label)).toEqual([
"Drive",
"Contacts",
]);
expect(
status.json.google.tools.find((t: { name: string }) => t.name === "drive_create_doc").group,
).toBe("drive");

// Gmail still works; asking for Drive fails as an unknown tool, and the prompt says why.
const chat = await ada("/api/chats", { method: "POST", json: { content: "Find the Q3 doc" } });
script([
{ toolCalls: [{ name: "drive_search", id: "d1", input: { query: "Q3" } }] },
{ text: "Reconnect Google to give me Drive." },
]);
await worker.processNow(chat.json.runId);
expect((await toolCall(chat.json.runId, "d1")).status).not.toBe("succeeded");
const prompt = JSON.stringify(model.calls[0]?.prompt);
expect(prompt).toContain("Drive and Contacts: not granted yet");

await connectGoogle(ada);
expect((await ada("/api/integrations")).json.google.connection.missing).toEqual([]);
});

test("“email Sam”: a contact's address goes through the normal approval, no extra warning", async () => {
const ada = await signUp("contacts@gtools.test");
await connectGoogle(ada);
google.state.contacts.push({ name: "Sam Rivera", emails: ["sam@rivera.example"] });
const chat = await ada("/api/chats", {
method: "POST",
json: { content: "Email Sam that the offsite moved to Friday" },
});
const send = {
to: ["sam@rivera.example"],
subject: "Offsite moved",
body: "Hi Sam, the offsite is now on Friday.",
};
script([
{ toolCalls: [{ name: "contacts_search", id: "c1", input: { query: "Sam" } }] },
{ toolCalls: [{ name: "gmail_send", id: "s1", input: send }] },
{ text: "Sent." },
]);
await worker.processNow(chat.json.runId);
expect((await toolCall(chat.json.runId, "c1")).status).toBe("succeeded");
expect(google.state.peopleQueries.slice(0, 2)).toEqual(["contacts:", "other:"]);
const waiting = await toolCall(chat.json.runId, "s1");
expect(waiting.status).toBe("awaiting_approval");
expect(waiting.decisionReason ?? "").not.toContain("New recipient");
expect((await run(chat.json.runId)).tainted).toBe(false);
});

test("creating a Doc asks first, then uploads it as a Google Doc", async () => {
const ada = await signUp("docs@gtools.test");
await connectGoogle(ada);
await ada("/api/settings", { method: "PUT", json: { undoSeconds: 0 } });
const chat = await ada("/api/chats", {
method: "POST",
json: { content: "Write up the launch plan as a doc" },
});
const input = { title: "Launch plan", body: "# Launch\n\n- QA\n- Announce" };
script([{ toolCalls: [{ name: "drive_create_doc", id: "w1", input }] }, { text: "Created." }]);
await worker.processNow(chat.json.runId);
const waiting = await toolCall(chat.json.runId, "w1");
expect(waiting.status).toBe("awaiting_approval");
expect(waiting.preview).toBe("Create Google Doc “Launch plan”");
expect(google.state.createdDocs).toHaveLength(0);

await ada(`/api/runs/${chat.json.runId}/tool-calls/w1/decision`, {
method: "POST",
json: { approved: true },
});
await worker.processNow(chat.json.runId);
const done = await toolCall(chat.json.runId, "w1");
expect(done.status).toBe("succeeded");
expect(JSON.stringify(done.output)).toContain("https://docs.google.com/document/d/");
expect(google.state.createdDocs.map((d) => d.name)).toEqual(["Launch plan"]);
expect(google.state.createdDocs[0]!.html).toContain("<h1>Launch</h1>");
});
34 changes: 25 additions & 9 deletions apps/server/src/notify/slack-api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { type Db, type SecretBox, schema } from "@synapse/db";
import { SLACK_USER_SCOPES } from "@synapse/tools";
import { and, eq, ne } from "drizzle-orm";

/** Bot scopes: post and update DMs, open the DM with the person who connected. */
Expand Down Expand Up @@ -53,6 +54,8 @@ export async function slackAuthorizationUrl(
url.search = new URLSearchParams({
client_id: app.clientId,
scope: SLACK_SCOPES.join(","),
// A user token too, so the agent's Slack tools can search, read and post as this person.
user_scope: SLACK_USER_SCOPES.join(","),
redirect_uri: slackRedirectUri(app),
state,
}).toString();
Expand All @@ -67,7 +70,7 @@ interface OAuthAccess {
scope?: string;
bot_user_id?: string;
team?: { id: string; name: string };
authed_user?: { id: string };
authed_user?: { id: string; scope?: string; access_token?: string };
}

/** Exchanges the callback code and saves the Slack account for the user who started it. */
Expand Down Expand Up @@ -120,13 +123,23 @@ export async function completeSlackAuth(
if (taken)
throw new SlackAuthError("That Slack account is already connected to another Synapse user.");

// Without every user scope the agent's Slack tools stay off; approvals in Slack still work.
const user = token.authed_user;
const userGranted = (user.scope ?? "").split(",");
const userToken =
user.access_token && SLACK_USER_SCOPES.every((s) => userGranted.includes(s))
? await box.seal(user.access_token)
: null;

const values = {
teamId: token.team.id,
teamName: token.team.name,
slackUserId: token.authed_user.id,
botUserId: token.bot_user_id ?? null,
botToken: await box.seal(token.access_token),
scopes: token.scope ?? "",
userToken,
userScopes: userToken ? (user.scope ?? "") : "",
status: "active" as const,
lastError: null,
};
Expand All @@ -138,8 +151,9 @@ export async function completeSlackAuth(
}

/**
* Deletes the user's Slack account. The bot token is revoked at Slack unless someone else
* on this server uses the same workspace install (Slack issues one bot token per workspace).
* Deletes the user's Slack account. The user token is always revoked at Slack; the bot token
* is too unless someone else on this server uses the same workspace install (Slack issues
* one bot token per workspace).
*/
export async function disconnectSlack(
app: SlackAppConfig | null,
Expand All @@ -158,13 +172,15 @@ export async function disconnectSlack(
.where(
and(eq(schema.slackAccounts.teamId, row.teamId), ne(schema.slackAccounts.userId, userId)),
);
if (app && !shared) {
try {
await slackCall(app, await box.open(row.botToken), "auth.revoke", {});
} catch {
// Revocation is a courtesy; the token is deleted either way.
const revoke = [...(row.userToken ? [row.userToken] : []), ...(shared ? [] : [row.botToken])];
if (app)
for (const sealed of revoke) {
try {
await slackCall(app, await box.open(sealed), "auth.revoke", {});
} catch {
// Revocation is a courtesy; the token is deleted either way.
}
}
}
await db.delete(schema.slackAccounts).where(eq(schema.slackAccounts.userId, userId));
}

Expand Down
Loading
Loading