---
name: inseam-connector
description: Write a connector that attaches a new kind of host to an inseam network. Use when a person asks an agent to connect a data source inseam has no built-in connector for.
metadata:
  release: 1
  sources: [arch/09-connectors-and-plugins.md, arch/11-credentials-and-oauth.md, arch/decisions/0024-host-scope.md]
---

# Writing an inseam connector

A connector is one JavaScript module that speaks a host's protocol behind a small interface. It runs sandboxed in
the network: it holds no credential, it reaches only the origins its host approved, and every outbound request
goes through the egress gate, which attaches the credential. Status at release 1: the trial facet that runs
a submitted connector is **planned**. Today the built-in connectors are `fs` (a folder on a device) and `bucket`
(the pad, S3, R2). A host is added through the macOS app, the CLI or `POST https://app.inseam.io/v1/hosts`.

## Rules

- Paths are the host-local part of the location address `inseam://{network}/{host}/{path}`. You own their
  canonical form. Where the host has a stable native id (Drive, Gmail, Notion), use it as the path and put the
  hierarchy in `extra`.
- Expose sub-objects people save on their own (attachments) as objects with their own path, read as their
  decoded bytes.
- Report `nativeId` only when the host never reuses it. Report `nativeHash` on metered hosts where reading bytes
  is expensive.
- Declare every dimension a scope may use, each with a type and the `Entry` field it reads; mark exactly one as
  `ordering: true`. Put the kind's default excludes in `defaultScope`.
- Never read a credential. Declare what you need in `auth` and make requests to your origins; the gate completes
  them.
- Read the envelope faithfully: the parties as the host states them, the direction, and the host's
  authentication results. Do not invent participants.

## The host interface

```ts
interface Connector {
  describe(): {
    kind: string;
    capabilities: ("list" | "stat" | "read" | "watch" | "write" | "delete")[];
    auth: AuthSpec;
    dimensions: Record<string, Dimension>;   // what a scope may filter and order on
    listing: "free" | "metered";             // sets the default for window.outside
    defaultScope?: Scope;                    // the kind's default; the default excludes live here
    writes?: Writes;                         // what the host accepts on write; absent means read-only
  };
  list(opts: { prefix?: string; cursor?: string; since?: string; order?: "newest" }): Promise<{ entries: Entry[]; cursor?: string }>;
  stat(path: string): Promise<Entry | null>;
  read(path: string, opts?: { range?: [number, number] }): Promise<ReadableStream<Uint8Array>>;
  watch?(emit: (ev: ChangeEvent) => void): Promise<() => void>;
  webhook?(req: Request): Promise<ChangeEvent[]>;
  write?(path: string, body: ReadableStream<Uint8Array>, opts?: { ifMatch?: string }): Promise<Entry>;
  delete?(path: string): Promise<void>;
}
interface Entry { path: string; size?: number; mtime?: string; etag?: string; mime?: string; nativeHash?: string; nativeId?: string; envelope?: Envelope; extra?: Record<string, unknown> }
interface Envelope { direction: "sent" | "received" | "owned"; parties: { identity: string; role: string }[]; auth?: { dkim?: boolean; spf?: boolean; dmarc?: boolean }; list?: string }
interface ChangeEvent { type: "put" | "delete" | "move" | "rescan"; path: string; from?: string; entry?: Entry }
interface Writes { mimes: string[] | "*"; create: boolean; replace: boolean; delete: boolean; maxBytes?: number }
interface Dimension {
  type: "string" | "path" | "enum" | "bytes" | "integer" | "datetime" | "boolean";
  from: "path" | "size" | "mtime" | "mime" | `extra.${string}`;
  ordering?: true;          // exactly one dimension per connector; the window runs along it
  values?: string[];        // for enum
  description: string;
}
type AuthSpec = {
  fields?: { name: string; label: string; secret: boolean; pattern?: string; help?: string }[];
  instructions?: string;
  acquire: { type: "none" } | { type: "static" } | { type: "oauth2"; grant: "authorization_code" | "device_code"; authorizationUrl?: string; deviceUrl?: string; tokenUrl: string; scopes: string[]; pkce: true; clientSources: ("inseam" | "dcr" | "network")[] } | { type: "exchange"; request: { method: "GET" | "POST"; url: string; headers?: Record<string, string>; json?: unknown; form?: Record<string, string> }; extract: Record<string, string>; expiresIn?: string | number; expiresAt?: string; renewOn?: number[] };
  present: ({ in: "header" | "query" | "cookie"; name: string; value: string } | { in: "basic"; username: string; password: string } | { in: "aws_sigv4"; accessKeyId: string; secretAccessKey: string; region: string; service: string })[];
  origins: string[];
};
```

## Scope

A scope is plain data: a window along the ordering dimension, an ordered rule list, limits. Predicates name only
your dimensions. See the reference page on scope for the exact grammar.

## Conformance

A trial runs these checks against the real host with the gate in audit mode (reads allowed, writes refused,
every origin recorded). Every check must pass before a person can approve the connector.

1. describe() returns a kind, capabilities, an AuthSpec with origins, and dimensions with exactly one ordering: true.
2. list() returns entries; a cursor round-trips; list({order: "newest"}) is honoured when the host can.
3. Every declared dimension is present on every listed entry and has the declared type.
4. stat(path) of a listed entry matches the listing.
5. read(path) of a small sample streams bytes; read with a range returns that range.
6. Paths are canonical: list → stat → list again gives the same path for the same object.
7. defaultScope validates against the declared dimensions.
8. If watch or webhook is claimed: a parsed event has a type, a path and, for move, a from.
9. If nativeId is reported: a rename of a scratch object keeps its nativeId, or a move event is emitted.
10. If writes is declared: after install, on a scratch prefix a person names, create, read back the same bytes,
    replace with ifMatch, delete, once per declared mime.
11. Only the declared origins were used. A refusal from the gate fails the trial and names the origin.

## Submitting

Planned: `trial_connector` on the MCP endpoint takes the module and a host configuration, runs the checks in a
trial facet and returns the report and the origins used; a person approves in the browser; `install_connector`
starts the first crawl.
