> **Demo instance.** Google APIs are mocked. Fixture data belongs to the fictional persona Thomas Masaryk (`thomas.masaryk@example.com`). No real Google account is connected. The request/approve protocol is identical to production.

# Seneschal

> Seneschal is a permission broker between AI agents and Google services.
> Users stay in control — you never see their credentials.
> All service access requires explicit, per-request user approval.

This URL content-negotiates:

| `Accept` | Response |
|----------|----------|
| `application/json` | Structured summary (register from this) |
| `text/markdown` or `text/plain` | This full protocol document |
| missing or `*/*` | Markdown (agent-friendly default) |
| `text/html` | Browser-rendered HTML |

Also: `https://tinthe.dev/seneschal-demo/openapi.json`, `https://tinthe.dev/seneschal-demo/docs`, `https://tinthe.dev/seneschal-demo/llms.txt`.

---

## Getting an Invite Token

No invite token was provided. Visit the [Seneschal homepage](https://tinthe.dev/seneschal-demo) and generate an invite link from your dashboard.

---

## Quickstart

1. **Register** — `POST https://tinthe.dev/seneschal-demo/api/register` with your `invite_token`, receive an `agent_token`
2. **Request a capability** — `POST https://tinthe.dev/seneschal-demo/api/request` with the service and action you need
3. **Notify the user** — send the `approval_url` to them (Telegram, email, etc.) and wait
4. **Poll for status** — `GET https://tinthe.dev/seneschal-demo/api/request/{request_id}` until status is `approved` or `completed`
5. **Read data** — `GET https://tinthe.dev/seneschal-demo/api/data/{service}` once the grant is approved

---

## Endpoints

| Name | Endpoint |
|------|----------|
| Register | `POST https://tinthe.dev/seneschal-demo/api/register` |
| Request capability | `POST https://tinthe.dev/seneschal-demo/api/request` |
| Poll request | `GET https://tinthe.dev/seneschal-demo/api/request/{request_id}` |
| Read calendar | `GET https://tinthe.dev/seneschal-demo/api/data/calendar` |
| Read Gmail | `GET https://tinthe.dev/seneschal-demo/api/data/gmail` |
| Read Drive | `GET https://tinthe.dev/seneschal-demo/api/data/drive` |
| Swagger UI | `https://tinthe.dev/seneschal-demo/docs` |
| ReDoc | `https://tinthe.dev/seneschal-demo/redoc` |
| OpenAPI JSON | `https://tinthe.dev/seneschal-demo/openapi.json` |

---

## Services & Actions

- **calendar**: `read` (grant), `create_event` (one-time action)
- **gmail**: `read` (grant), `search_gmail` (one-time action), `read_email` (one-time action), `read_thread` (one-time action), `send_email` (one-time action)
- **drive**: `read` (grant, folder-scoped), `read_file`, `read_files`, `create_file`, `append_to_doc`, `add_sheet_tab` (all one-time actions)

### Grant — ongoing access

Request type `grant` — user approves once, agent calls the data endpoint until revoked.

```json
{
  "request_type": "grant",
  "service": "calendar",
  "action": "read",
  "preview": {
    "scopes": ["read"],
    "config": {"time_window_days": 30}
  }
}
```

#### Grants are binary per service

A grant for `(service, "read")` is binary — either the agent can read that service or it cannot. Re-granting **replaces** the existing active grant in the same transaction; grants do not stack. If you ask for `gmail:read` while you already have one, the new approval supersedes the old config.

This means:

- The grant config defines the **scope envelope** the user authorized at most.
- Per-call query parameters let you narrow within that envelope at read time — they cannot widen it.
- To widen scope, re-request the grant with the new envelope. The previous grant is revoked atomically when the new one is approved.

#### Gmail `read` — grant-time config (the envelope)

Pass these under `preview.config` on a Gmail `read` grant:

- `max_results` (int, default `20`) — cap on results per call. Per-call `?max_results=` is clamped to this.
- `query` (string, default `"in:inbox"`) — any [Gmail search query](https://support.google.com/mail/answer/7190). Defines the broadest set of messages the agent may ever see; per-call narrowing is ANDed in.

```json
{
  "request_type": "grant",
  "service": "gmail",
  "action": "read",
  "preview": {
    "scopes": ["read"],
    "config": {"max_results": 50, "query": "in:inbox"}
  }
}
```

#### Gmail `read` — per-call params (mode)

`GET https://tinthe.dev/seneschal-demo/api/data/gmail` accepts these query parameters; each call picks how to slice the granted envelope:

- `?unread_only=true` — appends `is:unread` to the granted query. Use for "what still needs the user's attention?" workflows. With `unread_only=true` you get the `max_results` most recent **unread** messages, which can include older unread emails buried under read traffic.
- `?q=<extra-clauses>` — extra Gmail search syntax ANDed into the granted query (e.g. `?q=from:alice@example.com`). Cannot widen scope.
- `?max_results=<N>` — capped to the configured maximum.

```
GET /api/data/gmail                                  # whatever the granted query selects
GET /api/data/gmail?unread_only=true                 # only unread, within the same envelope
GET /api/data/gmail?q=from:alice@example.com&max_results=10
```

### Action — one-time execution

Request type `action` — executes once on user approval.

```json
{
  "request_type": "action",
  "service": "gmail",
  "action": "send_email",
  "preview": {
    "to": "user@example.com",
    "subject": "Hello",
    "body": "Hello from your agent."
  }
}
```

#### `send_email` — reply in thread

To make the outgoing message appear as a continuation of an existing thread — both for you and the recipient — include these optional fields on the `send_email` preview:

- `thread_id` — Gmail thread ID (from `search_gmail` or `read_thread` results).
- `in_reply_to` — the `Message-ID` header of the message you are replying to (the `message_id_header` field on any message returned by `read_thread` / `read_email`).
- `references` — the full `References` chain from the parent message. If omitted, `in_reply_to` alone is used, which is correct for replies to a root message.

When any threading field is present the subject is automatically prefixed with `Re: ` if it is not already. No extra approval step is required — the user approves the `send_email` exactly as today.

```json
{
  "request_type": "action",
  "service": "gmail",
  "action": "send_email",
  "preview": {
    "to": "alice@example.com",
    "subject": "Project update",
    "body": "Thanks — numbers look good.",
    "thread_id": "17e123456789abcd",
    "in_reply_to": "<CA+abc123@mail.example.com>",
    "references": "<root@mail.example.com> <CA+abc123@mail.example.com>"
  }
}
```

#### `read_thread` — fetch a full email thread

Pass the Gmail API thread ID as `thread_id`. This is a hex string (e.g. `17e123456789abcd`) — **not** the identifier from the Gmail web URL, which uses a different encoding rejected by the API. Use `search_gmail` first to find the correct thread ID.

```json
{
  "request_type": "action",
  "service": "gmail",
  "action": "read_thread",
  "preview": {
    "thread_id": "17e123456789abcd"
  }
}
```

The approved result contains every message in the thread with full body text. The `message_id_header` field is the RFC 2822 `Message-ID` header — feed it back into `send_email` as `in_reply_to` when replying.

```json
{
  "thread_id": "17e123456789abcd",
  "message_count": 3,
  "messages": [
    {
      "id": "...",
      "from": "alice@example.com",
      "to": "bob@example.com",
      "subject": "Project update",
      "date": "Tue, 7 Apr 2026 10:00:00 +0000",
      "message_id_header": "<CA+abc123@mail.example.com>",
      "body": "..."
    }
  ]
}
```

#### `search_gmail` — find emails by query

Search the user's Gmail and get back message metadata including `thread_id` fields. Use this when the user wants to share an email with you — they describe it, you search, then use the returned `thread_id` to request `read_thread`.

Gmail search syntax applies: `subject:`, `from:`, `after:`, `before:`, `in:inbox`, etc.

```json
{
  "request_type": "action",
  "service": "gmail",
  "action": "search_gmail",
  "preview": {
    "query": "subject:\"Q2 budget\" from:alice@example.com",
    "max_results": 5
  }
}
```

The approved result:

```json
{
  "query": "subject:\"Q2 budget\" from:alice@example.com",
  "results": [
    {
      "id": "msg-hex-id",
      "thread_id": "17e123456789abcd",
      "from": "alice@example.com",
      "subject": "Q2 budget review",
      "date": "Mon, 6 Apr 2026 09:00:00 +0000",
      "snippet": "Hi, please find the attached..."
    }
  ]
}
```

### Drive — folder grants + standalone reads

Drive supports two access patterns:

- **Folder grant** (`read` capability) — the user pastes a folder URL once; the agent can
  list metadata, search, and perform write actions (`create_file`, `append_to_doc`,
  `add_sheet_tab`) bounded to that folder.
- **Standalone file reads** (`read_file`, `read_files`) — the user pastes one or more file
  URLs; the agent gets the content of those specific files after per-request approval.
  No folder grant is required. Use this for "here are some links, synthesize something"
  workflows where the files are not co-located.

Writes stay folder-scoped; reads do not.

#### `drive` `read` — granted folder

```json
{
  "request_type": "grant",
  "service": "drive",
  "action": "read",
  "preview": {
    "scopes": ["read"],
    "config": {
      "folder_url": "https://drive.google.com/drive/folders/<folder-id>",
      "time_window_days": 30,
      "max_results": 50
    }
  }
}
```

After approval, `GET https://tinthe.dev/seneschal-demo/api/data/drive` returns metadata of files in the
folder (sorted by `modifiedTime` desc, within the time window). Per-call refinement
via URL params is allowed within the granted scope:

- `?query=name+contains+'budget'` — extra Drive search syntax ANDed into the folder
  scope. Cannot widen scope.
- `?max_results=10` — capped to the configured maximum.

Each metadata entry includes `id`, `name`, `mimeType`, `modifiedTime`, `parents`,
`lastModifyingUser`, and `webViewLink`. No file content — that requires `read_file`.

#### `read_file` — one-time, fetch text content of a single file

`file_id` accepts any Google Drive URL (Docs, Sheets, Slides, `drive.google.com/file/d/...`)
or a bare file ID. No prior folder grant is required — the user sees the file's metadata
(name, mime type, owner, last modified, size) and approves per request, same flow as
`send_email` or `read_thread`.

```json
{
  "request_type": "action",
  "service": "drive",
  "action": "read_file",
  "preview": {
    "file_id": "https://docs.google.com/document/d/1abc.../edit"
  }
}
```

Result includes `content` as text: Google Docs export as markdown, Sheets as CSV,
plain text/markdown directly. Binary types return `content: null`. Files whose names
match a sensitive-keyword list (`password`, `tax`, `passport`, `ssn`, etc.) return
metadata with `redacted: true` instead of content.

If the file happens to be inside a folder the agent already has a grant on, the audit
log tags the read as folder-scoped; otherwise it is tagged standalone. The agent does
not need to know which path applies — pass the URL/ID and Seneschal figures it out.

#### `read_files` — one-time, batch fetch of multiple files

Same as `read_file` but for a list of URLs/IDs. The user sees every file's metadata on
a single approval screen and approves them as a batch. Per-file failures (bad URL,
unreachable file, sensitive filename) are isolated and surface in the per-file result.

```json
{
  "request_type": "action",
  "service": "drive",
  "action": "read_files",
  "preview": {
    "file_ids": [
      "https://docs.google.com/document/d/1abc.../edit",
      "https://docs.google.com/spreadsheets/d/1xyz.../edit",
      "1RawFileIdAlsoWorks"
    ]
  }
}
```

Result:

```json
{
  "total": 3,
  "files": [
    {"file_id": "1abc...", "name": "Brief", "mimeType": "...", "content": "..."},
    {"file_id": "1xyz...", "name": "Tracker", "mimeType": "...", "content": "..."},
    {"file_id": "bad-id", "error": "..."}
  ]
}
```

#### `create_file` — one-time, write a new file inside the granted folder

```json
{
  "request_type": "action",
  "service": "drive",
  "action": "create_file",
  "preview": {
    "name": "Daily Notes 2026-05-03",
    "mime_type": "application/vnd.google-apps.document",
    "content": "# Today\n\n- ...",
    "parent_folder_id": "1xyz..."
  }
}
```

Supported `mime_type` values:

- `application/vnd.google-apps.document` — Google Doc; `content` is inserted as text.
- `application/vnd.google-apps.spreadsheet` — Google Sheet; populate `rows` (list of
  lists) instead of `content` for initial values.
- `text/plain`, `text/markdown`, `application/json`, etc. — plain Drive file with
  `content` as raw bytes.

`parent_folder_id` must equal the granted folder ID. Get it from any `parents` entry
returned by `GET /api/data/drive`.

#### `append_to_doc` — one-time, append text to an existing Google Doc in the granted folder

```json
{
  "request_type": "action",
  "service": "drive",
  "action": "append_to_doc",
  "preview": {
    "doc_id": "1abc...",
    "content": "## 2026-05-03\n\nMeeting notes ..."
  }
}
```

Inserts `content` at the end of the doc — the existing body is not modified. Use this
for journal/log-style writes. The doc must be inside the granted folder.

#### `add_sheet_tab` — one-time, add a new tab to an existing spreadsheet in the granted folder

```json
{
  "request_type": "action",
  "service": "drive",
  "action": "add_sheet_tab",
  "preview": {
    "spreadsheet_id": "1abc...",
    "tab_title": "AI Review 2026-05-03",
    "rows": [
      ["Item", "Status", "Note"],
      ["Brief", "OK", "Reviewed today"]
    ]
  }
}
```

Adds a new tab with the given title and populates it with `rows` (optional). Existing
tabs are untouched — this is the safest write primitive for spreadsheets because the
agent's output stays segregated from user-curated tabs. The spreadsheet must be inside
the granted folder.

---

## Agent Best Practices

### Persist your `agent_token`
After registration, save the `agent_token` to a config file or environment variable (e.g. `SENESCHAL_AGENT_TOKEN`). On every subsequent session, check for it first — if it exists, skip registration entirely. The `invite_token` is single-use; calling `/api/register` again will fail once it is consumed.

### Poll automatically after sending the approval URL
Do not wait for the user to confirm back to you. Immediately begin polling `GET https://tinthe.dev/seneschal-demo/api/request/{request_id}` in a loop (e.g. every 5 seconds) and continue as soon as the status becomes `approved`, `completed`, or `rejected`. This keeps the flow seamless from the user's perspective.

```
loop:
  sleep 5s
  status = GET /api/request/{request_id}
  if status == "approved" or "completed" → proceed
  if status == "rejected" or "expired" or "failed" → abort and inform user
```

### Handle `403` on data endpoints gracefully
A `403` response from `/api/data/{service}` means the grant was revoked by the user. Do not treat this as a fatal error — re-request the capability and send a new approval URL.

### Check for an existing token before registering
At session start, check your environment for a persisted `agent_token` before calling `/api/register`. Only register if no token is found.

---

## Notes

- All `/api/*` endpoints require: `Authorization: Bearer <agent_token>`
- `send_email`, `read_email`, `read_thread`, `search_gmail`, and `create_event` are one-time actions — the user approves each individually
- Capabilities can be revoked at any time; subsequent calls return `403`
- Your `invite_token` is **single-use** and expires in **72 hours**

---

## Full API Reference

- Interactive docs: [https://tinthe.dev/seneschal-demo/docs](https://tinthe.dev/seneschal-demo/docs)
- ReDoc: [https://tinthe.dev/seneschal-demo/redoc](https://tinthe.dev/seneschal-demo/redoc)
- OpenAPI JSON: [https://tinthe.dev/seneschal-demo/openapi.json](https://tinthe.dev/seneschal-demo/openapi.json)
