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
11 changes: 10 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,14 @@ RUN_MAX_STEPS=25
RUN_MAX_COST_USD=2
DAY_MAX_COST_USD=20

# ── Social login (optional) ──────────────────────────────────────────────────
# ── GitHub (optional) ────────────────────────────────────────────────────────
# One OAuth app powers "Sign in with GitHub" and the built-in GitHub tools. Register
# both callback URLs on it (see docs/github.md). Setup: docs/github.md
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# A separate OAuth app for the GitHub tools only (optional; set both or neither).
# GITHUB_TOOLS_CLIENT_ID=
# GITHUB_TOOLS_CLIENT_SECRET=

# ── Google (optional) ────────────────────────────────────────────────────────
# One OAuth app powers "Sign in with Google" and the built-in Gmail + Calendar
Expand All @@ -62,6 +67,10 @@ GOOGLE_CLIENT_SECRET=
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
# Built-in Linear tools (search, read, file and update issues, comment). From a Linear
# OAuth app (Settings → API → OAuth applications). Setup: docs/linear.md
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=

# ── Notifications (optional) ─────────────────────────────────────────────────
# Web Push for approvals on phones and desktops. Setup: docs/notifications.md
Expand Down
7 changes: 5 additions & 2 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, Google Calendar, Drive, Sheets, 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.
SynapseAI works in the services you already use (Gmail, Google Calendar, Drive, Sheets, Contacts, Slack, GitHub and Linear built in; 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 Down Expand Up @@ -39,12 +39,15 @@ Agent products that act in your tools share three problems: approvals that live
- 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))
- Built-in GitHub tools that act as you: search issues and pull requests, read a PR with its changed files, reviews and checks, see what's waiting on your review, read code, and comment, open, close, label or assign issues with approval. A repository that only showed up in something the agent read is flagged as new ([setup](docs/github.md))
- Built-in Linear tools that act as you: search and read issues, see what's assigned to you, file issues, move, reassign and relabel them, and comment. Every change asks for approval by default, with the new issue or comment shown as it will appear, and a team or issue that only showed up in content the agent read is flagged as new ([setup](docs/linear.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))
- Approvals that don't wait forever: one reminder, escalation to team admins, then expiry as an ordinary deny the agent sees. Optional two-person sign-off for irreversible actions (and sends): two different people, any deny ends it
- Plain-language risk facts on every approval, in the app and in push, Slack and email ("Sends to 2 people outside acme.com", "Can't be undone", "After reading outside content")
- Attach any file in chat (photos, videos, PDFs, documents; up to 25 MB each) with the paperclip, drag and drop, or paste. The agent reads text files and PDFs, looks at photos (PNG, JPEG, GIF, WebP up to 5 MB; text in a photo is treated as untrusted, like an email), and can attach any of your files to an email; the approval card shows exactly which files go out
- Exact numbers: the agent runs JavaScript in a QuickJS WebAssembly sandbox (`run_code`: no network, no disk, 5 s and 64 MB limits, fresh each call) for totals, averages, date math and reshaping data, instead of doing arithmetic in its head. It can read your attached CSV, JSON, text and PDF files and save a result file you can open or have it attach to an email
- Knowledge base: upload docs, hybrid full-text + vector search (pgvector, reciprocal rank fusion)
- Memory you can read and edit; the agent can add notes, but not from a tainted run without asking
- Web search (Tavily, Brave or Exa) and SSRF-guarded web fetch
Expand Down Expand Up @@ -98,7 +101,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, Calendar, Drive, Sheets and Contacts, Slack, MCP client with OAuth, SSRF guard, web search/fetch, knowledge base, memory. |
| `packages/tools` | Gmail, Calendar, Drive, Sheets and Contacts, Slack, GitHub, Linear, 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
11 changes: 10 additions & 1 deletion apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { automations, hooks } from "./routes/automations";
import { chats } from "./routes/chats";
import { clientMetadataDocument, connections, oauthCallback } from "./routes/connections";
import { files } from "./routes/files";
import { githubIntegration } from "./routes/github";
import { googleCallback, integrations } from "./routes/integrations";
import { linearIntegration } from "./routes/linear";
import { models } from "./routes/models";
import { onboarding } from "./routes/onboarding";
import { push } from "./routes/push";
Expand Down Expand Up @@ -54,7 +56,12 @@ export function createApp(services: Services) {
github: Boolean(config.GITHUB_CLIENT_ID),
google: Boolean(config.GOOGLE_CLIENT_ID),
},
integrations: { google: Boolean(config.google), slack: Boolean(config.slack) },
integrations: {
google: Boolean(config.google),
slack: Boolean(config.slack),
github: Boolean(config.github),
linear: Boolean(config.linear),
},
features: {
webSearch: Boolean(config.webSearch),
embeddings: Boolean(config.embedding),
Expand All @@ -80,6 +87,8 @@ export function createApp(services: Services) {
api.route("/connections", connections);
api.route("/integrations/google/callback", googleCallback);
api.route("/integrations/slack", slackIntegration);
api.route("/integrations/github", githubIntegration);
api.route("/integrations/linear", linearIntegration);
api.route("/integrations", integrations);
api.route("/policy/suggestions", suggestions);
api.route("/policy", policy);
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,19 @@ describe("config", () => {
const c = loadConfig({ ...base, AI_GATEWAY_API_KEY: "g", SYNAPSE_MODELS: "openai:gpt-5" });
expect(c.SYNAPSE_MODELS).toEqual(["openai:gpt-5"]);
});

test("the GitHub tools share the sign-in app unless a dedicated one is set", () => {
expect(loadConfig(base).github).toBeNull();
const signIn = { ...base, GITHUB_CLIENT_ID: "a", GITHUB_CLIENT_SECRET: "b" };
expect(loadConfig(signIn).github).toMatchObject({ clientId: "a", sharedWithSignIn: true });
const own = { ...signIn, GITHUB_TOOLS_CLIENT_ID: "c", GITHUB_TOOLS_CLIENT_SECRET: "d" };
expect(loadConfig(own).github).toEqual({
clientId: "c",
clientSecret: "d",
publicUrl: "http://localhost:3000",
});
expect(() => loadConfig({ ...signIn, GITHUB_TOOLS_CLIENT_ID: "c" })).toThrow(
"Set both GITHUB_TOOLS_CLIENT_ID",
);
});
});
47 changes: 46 additions & 1 deletion apps/server/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { parseModelRef } from "@synapse/core";
import type { GoogleAppConfig, WebSearchConfig } from "@synapse/tools";
import type {
GitHubAppConfig,
GoogleAppConfig,
LinearAppConfig,
WebSearchConfig,
} from "@synapse/tools";
import webpush from "web-push";
import { z } from "zod";
import type { SlackAppConfig } from "./notify/slack-api";
Expand Down Expand Up @@ -58,15 +63,22 @@ const Env = z.object({
RUN_MAX_COST_USD: z.coerce.number().default(2),
DAY_MAX_COST_USD: z.coerce.number().default(20),

/** One GitHub OAuth app can serve both sign-in and the GitHub tools (docs/github.md). */
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
/** A separate OAuth app for the GitHub tools; overrides the sign-in app for them. */
GITHUB_TOOLS_CLIENT_ID: optional(z.string()),
GITHUB_TOOLS_CLIENT_SECRET: optional(z.string()),
/** One Google OAuth app serves both "Sign in with Google" and the Gmail/Calendar integration. */
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
/** Slack app for approving from Slack (docs/slack.md). All three are needed. */
SLACK_CLIENT_ID: z.string().optional(),
SLACK_CLIENT_SECRET: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
/** Linear OAuth app for the built-in Linear tools (docs/linear.md). Both are needed. */
LINEAR_CLIENT_ID: optional(z.string()),
LINEAR_CLIENT_SECRET: optional(z.string()),
/** How often email triggers check for new mail. */
TRIGGER_POLL_SECONDS: z.coerce.number().int().min(30).default(120),

Expand Down Expand Up @@ -116,6 +128,8 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
}
: null;

const github = githubConfig(parsed);

const slack: SlackAppConfig | null =
parsed.SLACK_CLIENT_ID && parsed.SLACK_CLIENT_SECRET && parsed.SLACK_SIGNING_SECRET
? {
Expand All @@ -126,10 +140,21 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
}
: null;

const linear: LinearAppConfig | null =
parsed.LINEAR_CLIENT_ID && parsed.LINEAR_CLIENT_SECRET
? {
clientId: parsed.LINEAR_CLIENT_ID,
clientSecret: parsed.LINEAR_CLIENT_SECRET,
publicUrl: parsed.PUBLIC_URL,
}
: null;

return {
...parsed,
google,
github,
slack,
linear,
push: pushConfig(parsed),
email:
parsed.SMTP_URL && parsed.EMAIL_FROM
Expand Down Expand Up @@ -164,6 +189,26 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
};
}

/** The GitHub tools' OAuth app: a dedicated one when both override keys are set. */
function githubConfig(parsed: z.infer<typeof Env>): GitHubAppConfig | null {
if (Boolean(parsed.GITHUB_TOOLS_CLIENT_ID) !== Boolean(parsed.GITHUB_TOOLS_CLIENT_SECRET))
throw new Error("Set both GITHUB_TOOLS_CLIENT_ID and GITHUB_TOOLS_CLIENT_SECRET, or neither");
if (parsed.GITHUB_TOOLS_CLIENT_ID && parsed.GITHUB_TOOLS_CLIENT_SECRET)
return {
clientId: parsed.GITHUB_TOOLS_CLIENT_ID,
clientSecret: parsed.GITHUB_TOOLS_CLIENT_SECRET,
publicUrl: parsed.PUBLIC_URL,
};
if (parsed.GITHUB_CLIENT_ID && parsed.GITHUB_CLIENT_SECRET)
return {
clientId: parsed.GITHUB_CLIENT_ID,
clientSecret: parsed.GITHUB_CLIENT_SECRET,
publicUrl: parsed.PUBLIC_URL,
sharedWithSignIn: true,
};
return null;
}

/** Claude models, through the gateway when that is the only key configured. */
function defaultModels(parsed: z.infer<typeof Env>) {
if (parsed.AI_GATEWAY_API_KEY && !parsed.ANTHROPIC_API_KEY) {
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,61 @@ test("the agent sees an attached photo; the stored history keeps only its id", a
.where(eq(schema.messages.chatId, chat.json.chatId));
expect(JSON.stringify(rows)).not.toContain(Buffer.from(PNG).toString("base64"));
});

test("run_code totals an attached CSV and saves a file the user can open", async () => {
const ada = await signUp("coder@files.test");
const eve = await signUp("eve3@files.test");
const csv = 'item,amount\n"Hotel, 2 nights",412.30\nTaxi,38.75\n';
const sheet = (await upload(ada, "expenses.csv", "text/csv", csv)).json.file;
const chat = await ada("/api/chats", { method: "POST", json: { content: "Total these up" } });
const code = [
"const rows = csv(files['expenses.csv']).slice(1);",
"const total = rows.reduce((s, r) => s + Math.round(Number(r[1]) * 100), 0) / 100;",
"saveFile('total.txt', 'Total: ' + total);",
"return total;",
].join("\n");
script([
{ toolCalls: [{ name: "run_code", id: "c1", input: { code, files: [sheet.id] } }] },
{ text: "The total is 451.05." },
]);
await worker.processNow(chat.json.runId);
const call = await toolCall(chat.json.runId, "c1");
expect(call.status).toBe("succeeded");
expect(call.preview).toBe("Run code: const rows = csv(files['expenses.csv']).slice(1);");
const value = (call.output as { value: unknown }).value as {
result: number;
files: { fileId: string }[];
};
expect(value.result).toBe(451.05);

const saved = value.files[0]!.fileId;
const content = await ada(`/api/files/${saved}/content`);
expect(new TextDecoder().decode(content.bytes)).toBe("Total: 451.05");
expect((await eve(`/api/files/${saved}`)).status).toBe(404);
// The CSV's content flowed through, so the run counts as having read outside content.
const [run] = await services.database.db
.select()
.from(schema.runs)
.where(eq(schema.runs.id, chat.json.runId));
expect(run!.tainted).toBe(true);
});

test("run_code without files is just arithmetic and leaves the run untainted", async () => {
const ada = await signUp("math@files.test");
const chat = await ada("/api/chats", {
method: "POST",
json: { content: "What's 17.5% of 2,340?" },
});
script([
{ toolCalls: [{ name: "run_code", id: "m1", input: { code: "2340 * 0.175" } }] },
{ text: "409.5" },
]);
await worker.processNow(chat.json.runId);
const call = await toolCall(chat.json.runId, "m1");
expect((call.output as unknown as { value: { result: number } }).value.result).toBe(409.5);
const [run] = await services.database.db
.select()
.from(schema.runs)
.where(eq(schema.runs.id, chat.json.runId));
expect(run!.tainted).toBe(false);
});
Loading
Loading