Merge branch 'main' into dev

This commit is contained in:
Ramnique Singh 2026-04-09 07:06:56 +05:30
commit 89b6b963a2
9 changed files with 130 additions and 45 deletions

View file

@ -35,18 +35,18 @@ Rowboat connects to your email and meeting notes, builds a long-lived knowledge
You can do things like:
- `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph
- `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note)
- Track a person, company or topic through live notes
- Visualize, edit, and update your knowledge graph anytime (its just Markdown)
- Record voice memos that automatically capture and update key takeaways in the graph
Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads)
⭐ If you find Rowboat useful, please star the repo. It helps more people find it.
## Demo
[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw)
[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I)
[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I)
[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw)
---
@ -60,23 +60,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do
To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md).
### Voice input
To enable voice input and voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json:
To enable voice input and voice notes (optional), add a Deepgram API key in `~/.rowboat/config/deepgram.json`
### Voice output
To enable voice output (optional), add an ElevenLabs API key in `~/.rowboat/config/elevenlabs.json`
### Web search
To use Exa research search (optional), add the Exa API key in `~/.rowboat/config/exa-search.json`
### External tools
To enable external tools (optional), you can add any MCP server or use Composio tools by adding an API key in `~/.rowboat/config/composio.json`
All API key files use the same format:
```
{
"apiKey": "<key>"
}
```
### Voice output
To enable voice output (optional), add a Elevenlabs API key in ~/.rowboat/config/elevenlabs.json
### Web search
To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json.
(same format as above)
## What it does
Rowboat is a **local-first AI coworker** that can:
@ -90,8 +94,10 @@ Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Mark
Rowboat builds memory from the work you already do, including:
- **Gmail** (email)
- **Granola** (meeting notes)
- **Fireflies** (meeting notes)
- **Google Calendar**
- **Rowboat meeting notes** or **Fireflies**
It also contains a library of product integrations through Composio.dev
## How its different
@ -113,17 +119,15 @@ The result is memory that compounds, rather than retrieval that starts cold ever
- **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped
- **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions)
## Background agents
## Live notes
Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time.
Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note.
Examples:
- Draft email replies in the background (grounded in your past context and commitments)
- Generate a daily voice note each morning (agenda, priorities, upcoming meetings)
- Create recurring project updates from the latest emails/notes
- Keep your knowledge graph up to date as new information comes in
- Track a competitor or market topic across X, Reddit, and the news
- Monitor a person, project, or deal across web or your communications
- Keep a running summary of any subject you care about
You control what runs, when it runs, and what gets written back into your local Markdown vault.
Everything is written back into your local Markdown vault. You control what runs and when.
## Bring your own model

View file

@ -22,10 +22,11 @@ export interface AuthServerResult {
/**
* Create a local HTTP server to handle OAuth callback
* Listens on http://localhost:8080/oauth/callback
* Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds.
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (params: Record<string, string>) => void | Promise<void>
onCallback: (callbackUrl: URL) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -38,8 +39,6 @@ export function createAuthServer(
const url = new URL(req.url, `http://localhost:${port}`);
if (url.pathname === OAUTH_CALLBACK_PATH) {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
@ -65,9 +64,8 @@ export function createAuthServer(
return;
}
// Handle callback - either traditional OAuth with code/state or Composio-style notification
// Composio callbacks may not have code/state, just a notification that the flow completed
onCallback(Object.fromEntries(url.searchParams.entries()));
// Handle callback - pass full URL so params like iss (OpenID Connect) are preserved for token exchange
onCallback(url);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`

View file

@ -151,7 +151,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{
// Set up callback server
const timeoutRef: { current: NodeJS.Timeout | null } = { current: null };
let callbackHandled = false;
const { server } = await createAuthServer(8081, async () => {
const { server } = await createAuthServer(8081, async (_callbackUrl) => {
// Guard against duplicate callbacks (browser may send multiple requests)
if (callbackHandled) return;
callbackHandled = true;

View file

@ -14,6 +14,43 @@ import { emitOAuthEvent } from './ipc.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
/** Top-level openid-client messages that often wrap a more specific cause. */
const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']);
function firstCauseMessage(error: unknown): string | undefined {
if (error == null || typeof error !== 'object' || !('cause' in error)) {
return undefined;
}
const cause = (error as { cause?: unknown }).cause;
if (cause instanceof Error && cause.message.trim()) {
return cause.message;
}
if (typeof cause === 'string' && cause.trim()) {
return cause;
}
return undefined;
}
/**
* User-facing message for token-exchange failures. Prefer the first cause message when
* the top-level message is opaque (common for openid-client) or when code is OAUTH_INVALID_RESPONSE.
* The catch block below still logs the full cause chain for any error; this helper stays conservative.
*/
function getOAuthErrorMessage(error: unknown): string {
const msg = error instanceof Error ? error.message : 'Unknown error';
const code = error != null && typeof error === 'object' && 'code' in error
? (error as { code?: string }).code
: undefined;
const causeMsg = firstCauseMessage(error);
if (code === 'OAUTH_INVALID_RESPONSE' && causeMsg) {
return causeMsg;
}
if (causeMsg && OPAQUE_OAUTH_TOP_MESSAGES.has(msg.trim().toLowerCase())) {
return causeMsg;
}
return msg;
}
// Store active OAuth flows (state -> { codeVerifier, provider, config })
const activeFlows = new Map<string, {
codeVerifier: string;
@ -167,6 +204,11 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider, clientId);
// Persist Google client ID so it survives restarts and failed token exchanges
if (provider === 'google' && clientId) {
await oauthRepo.upsert(provider, { clientId });
}
// Generate PKCE codes
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
const state = oauthClient.generateState();
@ -187,12 +229,17 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Create callback server
let callbackHandled = false;
const { server } = await createAuthServer(8080, async (params: Record<string, string>) => {
const { server } = await createAuthServer(8080, async (callbackUrl) => {
// Guard against duplicate callbacks (browser may send multiple requests)
if (callbackHandled) return;
callbackHandled = true;
// Validate state
if (params.state !== state) {
const receivedState = callbackUrl.searchParams.get('state');
if (receivedState == null || receivedState === '') {
throw new Error(
'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.'
);
}
if (receivedState !== state) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
@ -202,10 +249,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom
}
try {
// Build callback URL for token exchange
const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`);
// Exchange code for tokens
// Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthClient.exchangeCodeForTokens(
flow.config,
@ -234,7 +278,15 @@ export async function connectProvider(provider: string, clientId?: string): Prom
emitOAuthEvent({ provider, success: true });
} catch (error) {
console.error('OAuth token exchange failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError)
let cause: unknown = error;
while (cause != null && typeof cause === 'object' && 'cause' in cause) {
cause = (cause as { cause?: unknown }).cause;
if (cause != null) {
console.error('[OAuth] Caused by:', cause);
}
}
const errorMessage = getOAuthErrorMessage(error);
emitOAuthEvent({ provider, success: false, error: errorMessage });
throw error;
} finally {
@ -302,8 +354,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
export async function getAccessToken(provider: string): Promise<string | null> {
try {
const oauthRepo = getOAuthRepo();
const { tokens } = await oauthRepo.read(provider);
let { tokens } = await oauthRepo.read(provider);
if (!tokens) {
return null;
}
@ -319,11 +371,12 @@ export async function getAccessToken(provider: string): Promise<string | null> {
try {
// Get configuration for refresh
const config = await getProviderConfiguration(provider);
// Refresh token, preserving existing scopes
const existingScopes = tokens.scopes;
const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes);
await oauthRepo.upsert(provider, { tokens });
await oauthRepo.upsert(provider, { tokens: refreshedTokens });
tokens = refreshedTokens;
} catch (error) {
const message = error instanceof Error ? error.message : 'Token refresh failed';
await oauthRepo.upsert(provider, { error: message });

View file

@ -517,6 +517,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
isConnecting: false,
}
}
// Hydrate in-memory Google client ID from persisted config so Connect can skip re-entry
if (config.google?.clientId) {
setGoogleClientId(config.google.clientId)
}
} catch (error) {
console.error('Failed to check connection status for providers:', error)
for (const provider of providers) {

View file

@ -426,6 +426,9 @@ export function useConnectors(active: boolean) {
try {
const result = await window.ipc.invoke('oauth:getState', null)
const config = result.config || {}
if (config.google?.clientId) {
setGoogleClientId(config.google.clientId)
}
const statusMap: Record<string, ProviderStatus> = {}
for (const provider of providers) {

View file

@ -18,6 +18,7 @@ const OAuthConfigSchema = z.object({
const ClientFacingConfigSchema = z.record(z.string(), z.object({
connected: z.boolean(),
error: z.string().nullable().optional(),
clientId: z.string().nullable().optional(),
}));
const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens);
@ -111,8 +112,9 @@ export class FSOAuthRepo implements IOAuthRepo {
clientFacingConfig[provider] = {
connected: !!providerConfig.tokens,
error: providerConfig.error,
clientId: providerConfig.clientId ?? null,
};
}
return clientFacingConfig;
return ClientFacingConfigSchema.parse(clientFacingConfig);
}
}

View file

@ -252,6 +252,7 @@ const ipcSchemas = {
connected: z.boolean(),
error: z.string().nullable().optional(),
userId: z.string().optional(),
clientId: z.string().nullable().optional(),
})),
}),
},

View file

@ -122,6 +122,14 @@ Select:
![Create OAuth Client ID (UWP)](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png)
### Authorized redirect URIs (if shown)
If your OAuth client configuration shows **Authorized redirect URIs**, add:
- `http://localhost:8080/oauth/callback`
Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback. (Some client types, e.g. UWP, may not expose redirect URIs; that is fine.)
---
## 7⃣ Copy the Client ID
@ -136,3 +144,15 @@ Copy the **Client ID** and paste it into Rowboat where prompted.
![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png)
---
## Troubleshooting
**Error after "Authorization Successful"**
If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"):
1. **Check the app logs** (e.g. terminal or dev tools) for the full error. The message there will often indicate the cause (e.g. redirect URI mismatch, missing parameter).
2. **Verify redirect URI in Google Cloud Console**: Open [Credentials → your OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients). If the client type allows **Authorized redirect URIs**, ensure `http://localhost:8080/oauth/callback` is listed exactly.
3. **Client type**: Use **Desktop** or **UWP** as the application type. A "Web application" client may require the redirect URI to be set and can behave differently with localhost.
---