Repository navigation
Background 05 fledge api
Research note dated 2026-10-09. Read for reasoning, not current fact; later decisions in
docs/decisions.mdanddocs/architecture-decisions.mdwin.Fledge is a separate project. Fledge-side findings are a list for the owner, never issues or pull requests in Fledge's repositories. The real Fledge client waits for Fledge's Stage C (D24). Notes 07 and 08 correct parts of the earlier notes, and note 03 assumes Tauri.
Moved from
docs/research/05-fledge-api.md(original at af3de53) on 2026-10-16.
Date: 2026-10-09. Read-only research over source code; no Fledge service, Supabase project or
production URL was called. Repositories are under C:\Users\<user>\Documents\GitHub\. Branches
read: Fledge-App restructure (HEAD f7d0a753), fledge-nest restructure/n2b, fledge-connect
restructure/a6-connect, fledge-desk restructure/a6-desk, and Fledge-Workspace main (what is
in production today). Confidence is marked (high), (medium) or (low) on each claim.
- What API could a desktop client call for leads, contacts and jobs, mail, calendar and docs, and which surfaces are stable rather than internal?
- How would a desktop app authenticate as the user, and can a third-party client use anything today?
- Is there an MCP server, what tools does it expose, and could Syrinx use it instead of a bespoke API?
- What do the modules'
/api/platform/{search,series-context,mcp-tools}endpoints return, and could Syrinx's shared index use the search endpoint? - What shape should Syrinx's
FledgeClienttrait take, and what must Fledge build for a real client?
- No public, stable client API exists for leads, mail, calendar or docs. Every module's REST routes are internal UI routes behind the browser's cookie session, and Nest's writes are Next.js Server Actions, whose IDs change between builds. Do not build Syrinx against either.
-
The sanctioned way for an outside client to act for a person is the platform's OAuth 2.1
authorization server, the one Fledge's MCP server uses. It supports dynamic client
registration, PKCE and
http://127.0.0.1loopback redirects, which is exactly the desktop flow. It landed on the localrestructurebranch today and is not deployed. -
The restructured MCP server registers 8 tools:
search,mail_thread,mail_draft,calendar_list,calendar_find_free_time,calendar_create_event,chat_postanddesk_search. It has no leads or contacts tool, because no consent scope covers customer data yet, and no tool lists an inbox or opens a document. - MCP alone is a poor fit for Syrinx's UI: its results are prose for a model, fenced as untrusted, with no structured output. Use the same OAuth tokens on a small structured REST surface; the "delegated surface" mechanism for that exists but honours identity only today.
-
/<module>/api/platform/*is shell-to-module plumbing on the person's Supabase session token, not a client API. Its search answers a live query with at most 5 hits per group and no change feed, so it can back a live palette provider but cannot feed Syrinx's local index. -
"New lead" maps cleanly onto Nest: a lead is a contact plus a job at the Lead stage, created
through
nest.capture_lead(). But it is service-role only, it takes no value, and Nest has no Company field, so the palette form needs Fledge-side work. - Build the stub now, and wait for Stage C for the real client. Nothing in the restructure is deployed until Stage C, and existing OAuth grants are re-issued at the cutover.
| Need | What exists | Where | Usable by Syrinx? |
|---|---|---|---|
| Create a lead |
nest.capture_lead(p_org, p_name, p_phone, p_email, p_source, p_notes, …) returns (job_id, contact_id, contact_outcome)
|
fledge-nest supabase/migrations/0006_nest_fold_leads.sql:71-91, grants :474-477
|
No: service role only (and Growth's growth_reader role, 0017:101) |
| Create a contact |
nest.match_or_create_contact (duplicate-matching insert) |
fledge-nest 0001_nest_hub_contacts.sql:501, grants :661-664
|
No: service role only |
| Create a job, list contacts, jobs, tasks | Server Actions createJob, findContacts, loadContacts, loadJobs, addTask
|
fledge-nest apps/app/app/(workbench)/{contacts,jobs,tasks}/actions.ts
|
No: Server Action IDs are "periodically recalculated between builds" (Fledge-App node_modules/next/dist/docs/01-app/02-guides/data-security.md:280) |
| List contacts, jobs (database) |
nest.list_contacts(org, search, cursor…), nest.list_jobs(…), granted to authenticated, keyset-paged |
fledge-nest 0001:697,750, 0005:779
|
Internal; would need a raw Supabase session and PostgREST access to the nest schema |
| Mail list, read, draft |
GET /connect/api/mail/threads?mailbox=&folder=&cursor=&limit= (limit 1–50, opaque nextCursor), GET …/mail/thread?mailbox=&id=, GET/POST …/mail/draft
|
fledge-connect apps/app/app/api/mail/{threads,thread,draft}/route.ts
|
Not today: cookie session only; a bearer token hits a stand-in that refuses every token (apps/app/lib/platform/access.ts:50-56, lib/connect-auth.ts:273) |
| Calendar list, create |
GET /connect/api/calendar/events?start=&end=&calendars=, POST …/calendar/events
|
fledge-connect apps/app/app/api/calendar/events/route.ts:39,77
|
Not today, same stand-in |
| Docs search, open |
GET /desk/api/docs/search?q=, GET /desk/api/docs/[id]
|
fledge-desk apps/app/app/api/docs/search/route.ts, docs/[id]/route.ts
|
Not today: bearer refused (apps/app/lib/access.ts:178-183) |
| Mail, calendar, docs for agents | MCP tools (see question 3) | Fledge-App packages/modules/src/mcp-tools.ts:87-146
|
Partly, after Stage C |
-
Leads are not a separate thing any more. Nest folds Growth's leads into contacts with a job
at the Lead stage, and
capture_leadis "the one way a lead comes in" (fledge-nest0006_nest_fold_leads.sql:2-16,56-60). (high) -
The palette's four fields do not all have a home. Name maps to the contact, Source to
nest.jobs.source(a slug matching^[a-z][a-z0-9_-]{0,31}$,0005_nest_pipelines_jobs.sql:313) and Est. value tonest.jobs.value_cents(integer cents,:308). Butcapture_leadhas no value parameter (0006:71-89), and no Company column or field exists anywhere in Nest (a repository-wide search for "company" finds nothing). Company would be a custom field (0013_nest_custom_fields_views.sql) or a new column. (high) -
Stable versus internal. The only surfaces written as cross-deployment contracts are the
SERIES wire types (Fledge-App
packages/series/src/contract.ts, "types only, deliberately"), the MCP forwarding contract (packages/auth/src/mcp-forward.ts), the search hit shape (apps/app/lib/search.ts:84) and the OAuth endpoints, whose discovery documents follow RFCs 8414 and 9728. Everything else is a module's internal route and may change with its screens. (medium: no document declares a public API, so "stable" is inferred from how the code is written.)
-
Sign-in is Supabase Auth (password, or Google through
apps/app/components/auth/GoogleButton.tsx) and the session is a host-only cookie onapp.*(Fledge-AppAGENTS.md). Syrinx's web tabs will carry that cookie; Syrinx's Rust core cannot use it. (high) -
The platform's OAuth 2.1 authorization server (ported from Workspace): Fledge-App
apps/app/lib/oauth/server.ts,apps/app/app/api/oauth/{register,token,revoke}/route.ts, the consent pageapps/app/app/(auth)/oauth/authorize/page.tsxandapps/app/app/.well-known/. OAuth 2.1 requires PKCE, a one-time secret proving that the app redeeming a code is the one that asked for it. What it supports:- Open dynamic client registration (RFC 7591), 10 per hour per IP; a client without a secret is
a public client authenticated by PKCE alone (
register/route.ts:22,48). (high) - Redirect URIs must be HTTPS or HTTP on
localhost,127.0.0.1or[::1]; custom schemes such assyrinx://are refused (server.ts:174-209). Matching is an exact string, so a desktop client "has to register every port it might use", up to 20 (server.ts:127,157; the reasoning is in Workspacemain:apps/connect/lib/oauth/server.ts:332-341). (high) - Access tokens last 1 hour and refresh tokens 30 days (
server.ts:38-40). Refresh tokens rotate, and reusing an old one revokes the whole grant (server.ts:559-563). Tokens are opaque and stored only as SHA-256 hashes. (high) - Each token is bound to one audience (RFC 8707 resource indicator): the MCP server
(
${APP_ORIGIN}/api/mcp) or the REST surface (${APP_ORIGIN}/api), never both (packages/auth/src/oauth-token.ts,tokenAudienceAllows). (high) - A grant names one person and one business. Only an owner, admin or member may approve (never a
guest), and only after their second factor if they use one (
apps/app/lib/oauth/consent.ts:26-40). Connected clients appear and can be revoked in Settings → Agents (apps/app/app/dashboard/settings/agents/page.tsx). (high)
- Open dynamic client registration (RFC 7591), 10 per hour per IP; a client without a secret is
a public client authenticated by PKCE alone (
-
Scopes (
apps/app/lib/oauth/scopes.ts:47-67):profile:read(always granted),mail:read,mail:draft,calendar:read,calendar:write,docs:read,files:read,chat:write, anddocs:write, which is marked unreachable and silently dropped because nothing serves it yet. There is deliberately nomail:send(:18-21), andcalendar_create_eventrefuses attendees because an invitation is an email (packages/modules/src/mcp-tools.ts:81-85). There is no scope for contacts, jobs or leads (mcp-tools.ts:29-35). The REST audience honoursprofile:readalone (scopes.ts:88); the deny-by-default, per-route "delegated surface" table a module would use to accept agent tokens is inpackages/auth/src/delegation.ts. (high) -
getClaims()bearer tokens on/<module>/api/platform/*are the person's Supabase session JWT, verified withgetClaims(). The business is resolved fromorganization_members, guests are refused, andpublic.has_module_accessis asked as that user (fledge-nestapps/app/lib/platform-api.ts:56-95; Connect also refuses a token owing its second factor). The shell calls them for the person (Fledge-Appapps/app/lib/search.ts); outside clients do not. (high) -
Supabase JWTs are deliberately not agent credentials. The verifier refuses them
(
oauth-token.ts:169), Connect refuses them as bearers because they could skip the second factor (connect-auth.ts:262-268), and the forwarding contract says minting one "would hand the agent everything their session reaches" (mcp-forward.ts). Module service secrets (MODULE_SERVICE_SECRET_<SLUG>,packages/auth/src/service.ts) are server-to-server only and refuse browser headers; they must never ship in a client. (high) -
Usable by a third-party desktop client today: only production Workspace's OAuth and MCP
server at
workspace.fledgesoftware.com/api/mcp(Workspacemain:handoff.md:40,298), with 15 tools and 24 REST routes accepting agent tokens (main:apps/connect/lib/oauth/delegated-surface.ts). It has no leads, and it is retired at Stage C, when grants are re-issued ("Re-grant the Claude connector", Fledge-AppDOCS/RESTRUCTURE-PLAN.md:360-361), so work built on it would be thrown away. (high)
MCP (Model Context Protocol) is a JSON-RPC protocol that AI clients use to discover and call "tools" on a server.
-
Workspace's MCP server and OAuth server now live in the shell:
POST /api/mcp(Fledge-Appapps/app/app/api/mcp/route.ts,apps/app/lib/mcp/*). It is stateless, refuses batches, and speaks protocol versions2025-11-25and2025-06-18(lib/mcp/protocol.ts:18-38). It offers tools only, withresources/*answered with an explanation (lib/mcp/server.ts:116-135). It merged into the localrestructurebranch on 2026-10-09 while this research ran;checklist.md:176still lists PLAT-MCP as in flight. (high for the code; medium for its status) -
The tool registry (
packages/modules/src/mcp-tools.ts:87-146), deny-by-default:search(the platform's own fan-out; scopesdocs:read,mail:readorfiles:read); Connect'smail_thread(mail:read),mail_draft(mail:draft),calendar_listandcalendar_find_free_time(calendar:read),calendar_create_event(calendar:write) andchat_post(chat:write); and Desk'sdesk_search. Nest, Growth, Rankings and Reach each serve a<slug>_searchtool, but the registry leaves them out until consent wording exists. Production Workspace had 15 tools, includingdocs_get,docs_tree,files_list,docs_createanddocs_append(Workspacemain:apps/connect/lib/mcp/catalog.ts:213-702); the document tools went to Desk's code, and Desk does not serve them yet. (high) -
The modules don't run forwarded calls yet. The shell forwards
tools/callto/<slug>/api/platform/mcp-toolswith the module's service secret plus the agent token inx-fledge-agent-token, and each module must re-verify withverifyForwardedCall(packages/auth/src/mcp-forward.ts). No branch of fledge-nest, fledge-connect or fledge-desk references it, and Connect's endpoint has no POST at all yet (fledge-connectapps/app/app/api/platform/mcp-tools/route.ts:12-25). So no tool call reaches module data end to end today. (high) -
Results are prose, fenced as untrusted. Every result is wrapped in a nonce-marked
"BEGIN WORKSPACE CONTENT" block (
apps/app/lib/mcp/untrusted.ts), and module tools return{ status, text }(fledge-connectapps/app/lib/mcp-tools/index.ts:54-77). No tool declares anoutputSchemaor returnsstructuredContent, which MCP has supported since2025-06-18. (high for Fledge; medium for the MCP spec detail, from memory) -
Limits: 60 tool calls per token and 120 requests per IP per minute, per serverless instance;
a write that times out is reported as "it may have happened" (
lib/mcp/limits.ts:21-27,mcp-forward.tsresponse table). (high) - Could Syrinx use MCP instead of a bespoke API? For authentication, yes: the same OAuth server. For data, not well: Syrinx would parse text written for a model, there is no inbox list, document read or leads tool, and an MCP-audienced token cannot call REST routes. MCP suits SERIES and Claude; Syrinx needs structured JSON. (medium: a recommendation)
| Endpoint | Request | Response | Caller today |
|---|---|---|---|
GET /<m>/api/platform/search?q= |
query, cut to 200 characters; under 2 characters returns recent records |
{ module, groups: [{ key, label, hits: [{ id, label, detail, href }] }] }, at most 5 hits per group, href a path on the app origin (fledge-nest apps/app/lib/search.ts:20-46) |
The shell's top-bar search, with a 2.5-second timeout per module (Fledge-App apps/app/lib/search.ts:56,84); the MCP search tool |
POST /<m>/api/platform/series-context |
{ orgId, source, ref?, query?, limit } |
{ items: [{ id, module, kind, title, text, href? }], unavailable? }, at most 3 items and about 4,000 characters each (Fledge-App packages/series/src/contract.ts; fledge-desk apps/app/lib/series-context.ts:37-40) |
SERIES's attach (+) menu. Sources: Nest contacts, jobs, tasks; Connect email, messages; Desk files, documents
|
GET / POST /<m>/api/platform/mcp-tools
|
POST { name, arguments }
|
GET { tools: [{ name, description, inputSchema }] }; POST { text } or an outcome |
The shell's MCP server (forwarding not yet adopted by modules) |
- Search groups: Nest returns
contacts,jobsandtasks(deep links to the record); Connect returnsmail,calendarandchat(links to/connect/inbox,/connect/calendarand/connect/chat, not to the item, fledge-connectapps/app/lib/search.ts:77-79,139-141); Desk returnsdocuments,tablesandfiles(fledge-deskapps/app/lib/search.ts:255-257). (high) -
Can Syrinx's shared index use the search endpoint? Not to build it. An index must list
everything and then fetch changes; this endpoint answers one query with five hits per group, no
cursor, on a Supabase JWT. It can serve as a live palette provider (query as you type, groups
under a "Fledge" scope) once a client-facing route accepts an OAuth token. The owner export
(
/<m>/api/platform/export, owner only, up to 150 MB,checklist.md:143) is a backup, not a sync feed. (high)
- Build the plugin against a trait with a stub now. Nothing real can be called before Stage C.
-
Authenticate the real client with the platform's OAuth server, asking for the REST
resource rather than MCP once Fledge's delegated REST surface exists. Register once per install
with a few fixed loopback ports (
http://127.0.0.1:<port>/fledge/callback). Open the authorize URL in an Syrinx web tab, where the person is already signed into Fledge, catch the code with a one-shot listener in the Rust core, and redeem it with PKCE (S256). Keep tokens in the OS keychain, and save each rotated refresh token before using it, since reusing an old one revokes the grant. -
"Open" means a web tab at the item's
href, which uses the person's normal Fledge session. - Offline: one cache layer (Syrinx's SQLite) wraps whichever client is in use. Offline reads come from the cache, marked stale. Writes go to an outbox with a client-made idempotency key. A write whose outcome is unknown is shown to the person, never retried, because Fledge's writes are not idempotent today.
Recommended trait sketch (async, so it uses the async-trait crate to stay usable as
Box<dyn FledgeClient>):
pub struct Session { pub user_id: String, pub org_id: String, pub business: String,
pub scopes: Vec<String>, pub modules: Vec<Module> } // what is reachable
pub enum Module { Nest, Connect, Desk }
pub struct Cursor(pub String); // opaque, server-issued
pub struct Page<T> { pub items: Vec<T>, pub next: Option<Cursor> }
pub struct IdempotencyKey(pub uuid::Uuid); // minted once per user action
pub struct NewLead { pub name: String, pub company: Option<String>, pub source: String /* slug */,
pub est_value_cents: Option<u64>, pub email: Option<String>, pub phone: Option<String> }
pub struct Lead { pub job_id: String, pub contact_id: String, pub name: String, pub stage: String,
pub value_cents: Option<u64>, pub source: Option<String>, pub href: String, pub updated_at: OffsetDateTime }
pub struct SearchHit { pub id: String, pub label: String, pub detail: String, pub href: String }
pub struct SearchGroup { pub module: Module, pub key: String, pub label: String, pub hits: Vec<SearchHit> }
// ThreadSummary, Thread, NewDraft, Event, NewEvent, TimeRange (UTC plus an IANA zone),
// DocHit, DocText (Markdown), ChangeBatch { items, deleted_ids, next: SyncCursor } likewise.
#[async_trait::async_trait]
pub trait FledgeClient: Send + Sync {
async fn session(&self) -> Result<Session, FledgeError>;
async fn list_leads(&self, page: Option<Cursor>) -> Result<Page<Lead>, FledgeError>;
async fn create_lead(&self, lead: NewLead, key: IdempotencyKey) -> Result<Lead, FledgeError>;
async fn list_threads(&self, folder: &str, page: Option<Cursor>) -> Result<Page<ThreadSummary>, FledgeError>;
async fn get_thread(&self, thread_id: &str) -> Result<Thread, FledgeError>;
async fn save_draft(&self, draft: NewDraft, key: IdempotencyKey) -> Result<DraftRef, FledgeError>;
async fn list_events(&self, range: TimeRange) -> Result<Vec<Event>, FledgeError>;
async fn create_event(&self, event: NewEvent, key: IdempotencyKey) -> Result<Event, FledgeError>;
async fn read_doc(&self, doc_id: &str) -> Result<DocText, FledgeError>;
async fn search(&self, query: &str) -> Result<Vec<SearchGroup>, FledgeError>; // live, capped
async fn changes(&self, since: Option<SyncCursor>) -> Result<ChangeBatch, FledgeError>; // index feed
}
pub enum FledgeError {
Offline, // no network: serve cache, queue the write
SignedOut, // 401: token gone or revoked, re-authorize
MissingScope { scope: String }, // 403 insufficient_scope: ask again for it
ModuleNotOwned { module: Module, store_href: String }, // "Add Connect to ..." prompt
Invalid { field: Option<String>, message: String }, // shown inline in the palette form
RateLimited { retry_after: Duration }, Unavailable { retry_after: Duration }, // never re-auth
OutcomeUnknown, // write timed out: it may have happened
Forbidden(String), NotFound, Server(String),
}Every item carries an href, so "open" needs no method of its own. Money is integer cents (as in
nest.jobs.value_cents) and ids are opaque strings. Pagination is an opaque cursor, which matches
both Connect's keyset nextCursor and Nest's keyset listings; search is unpaged because Fledge
caps it at five hits per group. Session.modules and Session.scopes let the plugin lock a command
with a prompt rather than show a dead one, as Fledge's own rule says (Fledge-App AGENTS.md). The
stub (StubFledgeClient) holds seeded fixtures in memory with a fault switch for each error
variant, so the palette's error, offline and lock states can be built now; a later
HttpFledgeClient must pass the same contract tests.
Candidate issues, in rough order. Each is one shippable unit.
-
Fledge-App: consent scopes for Nest data (
contacts:read, pluscontacts:writeor a narrowerleads:write) inapps/app/lib/oauth/scopes.tsand the MCP registry. The wording is a product decision; without it nothing outside Fledge can reach leads. -
fledge-nest:
POST /nest/api/leadsand a keyset-pagedGETunder a Nest delegated-surface table, callingnest.capture_leadafter verifying the token. Givecapture_leadavalue_cents, accept an idempotency key, and decide where Company lives. -
fledge-connect: replace the bearer stand-in (
apps/app/lib/platform/access.ts) with the platform verifier and a delegated-surface table (mail threads, thread, search and draft; calendar events GET and POST), adding the scopes toREST_SURFACE_SCOPES. Start from Workspace's table onmain. -
fledge-desk: the same for documents, plus a Markdown document read under
docs:read, the REST twin of Workspace'sdocs_get. -
Every module: adopt
verifyForwardedCallso the shell's MCP tools run at all. SERIES and Claude need this anyway. -
Fledge-App: a versioned, types-only client contract (modelled on
packages/series/src/contract.ts), defining the envelopes, error codes (insufficient_scope,module_not_ownedwithstore_href,temporarily_unavailablewithretry-after), cursors and a version header, so the routes in items 2–4 cannot drift with the screens. -
All modules: idempotency keys on writes. There is precedent in the shell's charge key and
the push
dedupe_key(checklist.md:139,461). -
Shell plus modules: a change feed for outside indexes (for example
GET /<m>/api/platform/changes?since=), returning id, kind, title, snippet,href,updated_atand deleted ids, filtered by what the grant may read. Unread counts for tile badges could ride along. -
Optional, Fledge-App: accept any port on loopback redirects (RFC 8252 §7.3) in
isRegisteredRedirect. Registering up to 20 ports works without it.
- Is Syrinx an ordinary third-party client that goes through the same consent screen as Claude (recommended), or a first-party client with a pre-registered id?
- What should the consent wording for contacts and leads say, and is creating a lead from outside Fledge acceptable?
- Should the Fledge rules (no
mail:send, no calendar invitations with attendees) hold for Syrinx? The recommendation is yes: Syrinx drafts, and the person sends from a Connect tab. - Where does a lead's Company live in Nest, and should
capture_leadtake an estimated value? - REST delegated surface (recommended) or MCP with structured output as Syrinx's data channel?
- When does Stage C happen? It decides when Syrinx's real client can be built, since grants made before it are re-issued.
- Fledge-App (
restructure):AGENTS.md;checklist.md;DOCS/RESTRUCTURE-PLAN.md;apps/app/lib/oauth/{server,scopes,consent,protected-resource}.ts;apps/app/app/api/oauth/*;apps/app/app/(auth)/oauth/authorize/page.tsx;apps/app/app/api/mcp/route.ts;apps/app/lib/mcp/{protocol,server,limits,untrusted}.ts;apps/app/lib/search.ts;packages/modules/src/mcp-tools.ts;packages/auth/src/{oauth-token,mcp-forward,delegation,service}.ts;packages/series/src/contract.ts;node_modules/next/dist/docs/01-app/02-guides/data-security.md. - fledge-nest:
apps/app/lib/{platform-api,search,mcp-tools,series-context}.ts,apps/app/lib/jobs/source.ts,apps/app/app/(workbench)/*/actions.ts,supabase/migrations/{0001,0005,0006,0013,0017}_*.sql. - fledge-connect:
apps/app/lib/{connect-auth,platform/access,platform-api,search}.ts,apps/app/lib/mcp-tools/*,apps/app/app/api/{mail,calendar,platform}/**/route.ts. - fledge-desk:
apps/app/lib/{access,mcp-tools,search,series-context}.ts,apps/app/app/api/docs/**/route.ts. - Fledge-Workspace (
main):apps/connect/lib/oauth/{server,delegated-surface}.ts,apps/connect/lib/mcp/catalog.ts,handoff.md.
Using the code
Background research
- 01 Shell and engine
- 02 Browser v1 scope
- 02a Tauri and wry facts
- 02b Default browser, import, signing
- 03 Core architecture
- 04 Forge and Notes
- 05 Fledge API
- 06 Engine and UI performance
- 07 Google sign-in and DRM
- 08 GPUI and wry on Windows
- 09 Phase 0 verdict
Ideas
In the repository