# Build Your Own Connector

This doc tells the Forge Help AI how to author a custom connector for
the user. The user asks something like _"build me a connector for
JIRA"_ or _"add a connector that scrapes my internal wiki"_; you walk
them through requirements, generate a manifest, and install it locally.

## Mental model

A connector is a YAML manifest that exposes **tools** the chat LLM
can call. Each tool runs in one of three places:

| `protocol` | Where it runs | Used for |
|---|---|---|
| `browser` (default) | User's tab, via the Forge browser extension | DOM-scraping the user's logged-in UI (Mantis, GitLab UI, Teams) |
| `http` | Forge server | Clean REST APIs with PAT auth (GitHub, Stripe, …) |
| `shell` | Forge server | Local CLI tools (`git`, `gh`, `kubectl`, …) |

Choose **browser** when the user is already logged in to a web app and
you don't want to manage tokens. Choose **http** when there's a clean
REST API. Choose **shell** when wrapping a local CLI.

## The interview (ask the user)

Before writing anything, ask:

1. **What service / site?** (e.g. JIRA Server at jira.acme.com)
2. **What actions are needed?** Read-only? Read + write? List of verbs.
3. **Auth?** Logged-in browser session, PAT, OAuth, local CLI?
4. **What settings does the user need to fill in?** (base_url, PAT, default project, …)

Stop after this; show the user a one-paragraph plan; let them adjust.

## Manifest shape

Required fields: `id`, `name`, `version`, and either `tools` or
`connectors[]`. Everything else is optional.

```yaml
id: jira                       # lowercase, alphanumerics + - / _
name: JIRA
icon: "📋"
version: "0.1.0"
author: "<user-provided>"
description: |
  Multi-line description shown in the marketplace.

# Optional. Locks install to Forge versions that have the required runtime features.
min_forge_version: "0.8.0"

# Browser runner. Default 'main'. Use 'isolated' for strict-CSP
# sites (Teams, github.com) that block eval in the page world.
runner: main

# Per-user settings rendered as a form in Settings → Connectors.
settings:
  base_url:
    type: string
    label: JIRA base URL
    required: true
  token:
    type: secret              # encrypted at rest (AES-256-GCM)
    label: Personal access token
    description: Create at <base_url>/secure/ViewProfile.jspa

# Where the extension finds the authenticated tab. {settings.*} expanded server-side.
host_match: "{base_url}/*"

# Substring detected after navigation → "user not logged in".
login_redirect: "/login.jsp"

tools:
  list_my_issues:
    description: List JIRA issues assigned to me.
    parameters:
      project: { type: string, description: "Limit to one project key (optional)" }
      status:  { type: select, options: ["open","done","all"], default: "open" }
      limit:   { type: number, default: 25 }
    # protocol omitted → browser
    page:
      url: "{base_url}/issues/?jql=assignee=currentUser()"
      on_target: "/issues/"   # skip navigation if URL already matches
    script: |
      const rows = Array.from(document.querySelectorAll('.issuerow'));
      return rows.slice(0, args.limit).map(r => ({
        key:     r.dataset.issuekey,
        title:   r.querySelector('.summary')?.textContent?.trim(),
        status:  r.querySelector('.status')?.textContent?.trim(),
        link:    r.querySelector('a.issue-link')?.href,
      }));

  add_comment:
    destructive: true          # extension prompts before running
    description: Add a comment to a JIRA issue.
    parameters:
      issue_key: { type: string, required: true }
      text:      { type: string, required: true }
    page:
      url: "{base_url}/browse/{args.issue_key}"
    script: |
      // Same-origin fetch with user's session cookie auto-attached
      const r = await fetch(`/rest/api/2/issue/${args.issue_key}/comment`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ body: args.text }),
      });
      if (!r.ok) return { _error: `HTTP ${r.status}: ${await r.text()}` };
      return { ok: true };
```

### Template tokens

- `{base_url}`, `{settings.<name>}` — expanded server-side at API
  response time from the user's saved settings.
- `{args.<name>}` — expanded at runtime from the LLM's tool input.
  In a `script` body, prefer `args.foo` (a JS identifier) over the
  literal `{args.foo}` string.

### `script` contract

- Receives `args` (the LLM's parsed parameters).
- Returns a JSON-serialisable value (no DOM nodes, no functions).
- Has access to `document`, `fetch`, `URL`, `Headers`, etc. (page context).
- Same-origin `fetch()` — the user's session cookies attach automatically.
- Must be **self-contained** — no closures over Forge / extension code,
  no `import`, no `require`.
- Throws are caught by the runner and surfaced as tool errors;
  prefer `return { _error: '...' }` for predictable failure messages.

### http protocol

For services with a clean REST API:

```yaml
tools:
  get_repo:
    protocol: http
    parameters:
      owner: { type: string, required: true }
      repo:  { type: string, required: true }
    request:
      method: GET
      url: "https://api.github.com/repos/{args.owner}/{args.repo}"
      headers:
        Accept: "application/vnd.github+json"
        Authorization: "Bearer {settings.token}"
    timeout_ms: 15000
```

### test block

A connector can ship a self-test so the Settings → Connectors UI's
**Test** button has something to call. Two probe kinds:

**`probe: http`** (default) — server issues a one-shot HTTP request
with `{settings.*}` expanded. Use for REST-API connectors with a
quick `/me`-style endpoint.

```yaml
test:
  description: "GET /api/v4/user — verifies token works"
  probe: http
  request:
    method: GET
    url: "{settings.base_url}/api/v4/user"
    headers:
      PRIVATE-TOKEN: "{settings.token}"
  ok_status: [200]                              # default [200]
  ok_template: "Authenticated as {{username}} ({{name}})"
  timeout_ms: 15000                              # default 15s
```

`ok_template` accepts `{{<json-path>}}` placeholders that resolve
against the parsed response body. Missing paths render `?`.

**`probe: browser`** — forwarded to the paired Forge browser
extension. The extension opens / acquires a tab matching
`host_match`, waits for navigation, and reports whether the final
URL contains `login_redirect`. Use for browser-side connectors
(Mantis, Teams, PMDB) where auth is the user's session cookie
rather than a token Forge can verify server-side.

```yaml
test:
  description: "Opens a Mantis tab and checks the session is alive."
  probe: browser
  timeout_ms: 30000                              # default 30s
```

No `request:` needed — the probe reuses the manifest's top-level
`host_match` + `login_redirect`.

**Extension wire contract** (for extension implementers):

```ts
// bridge method: 'connector.probe'
// params:
{
  pluginId: string;
  host_match: string;          // expanded with {settings.*}
  login_redirect?: string;     // expanded with {settings.*}
  runner: 'main' | 'isolated'; // inherits from manifest.runner
  timeout_ms: number;          // honour or cap, your call
}
// response:
{ ok: true,  url: '<final tab URL>' }
{ ok: false, error: 'login required' | '<other>', url?: string }
```

The extension's existing tab-acquisition logic already knows how
to handle `host_match` + `login_redirect`; the probe just runs the
acquire step and skips `executeScript`. If the extension isn't
connected, the Forge route surfaces a clear "install + sign in to
the Forge extension" message.

### shell protocol

For local CLI tools. **Every arg is templated independently** — no
shell injection.

```yaml
tools:
  git_log:
    protocol: shell
    parameters:
      repo: { type: string, required: true }
      n:    { type: number, default: 20 }
    command: ['git', '-C', '{args.repo}', 'log', '-n', '{args.n}', '--oneline']
    timeout_ms: 5000
```

## How you, the AI, install it

You have direct filesystem access to the running user's Forge data
directory. There are two paths:

### Path A — write the manifest directly (preferred for AI)

Find the data directory (usually `~/.forge/data/`, override via
`FORGE_DATA_DIR` env). Write:

```
<dataDir>/connectors/<id>/manifest.yaml
```

…then call the install-local API to register it:

```bash
curl -s -X POST http://localhost:8403/api/connectors/install-local \
  -H "X-Forge-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -nc --arg yaml "$(cat <dataDir>/connectors/<id>/manifest.yaml)" '{yaml: $yaml}')"
```

(See `lib/help-docs/CLAUDE.md` for the auth pattern.)

### Path B — generate a zip for hand-installation

If the user wants to share the connector with a colleague or store
it in version control, package it as a zip:

```
my-connector.zip
├── manifest.yaml
├── README.md          (optional)
├── icon.svg           (optional)
└── tools/             (optional — separate files when scripts grow long)
    └── list_my_issues.js
```

`manifest.yaml` must be at the root. The user then uploads via the
"+ Upload" button in Settings → Connectors (or drags-and-drops onto
the panel).

## Checklist before declaring done

- [ ] `id` is lowercase + URL-safe
- [ ] At least one tool defined
- [ ] `description` reads like one a stranger would understand
- [ ] `version: "0.1.0"` (or whatever the user specifies)
- [ ] `host_match` + `login_redirect` set for browser connectors
- [ ] Settings include any secrets as `type: secret` (encrypted at rest)
- [ ] Run a 1-tool smoke test by triggering the chat agent to call it,
      and surface any error to the user
- [ ] Tell the user where the manifest was saved + that they can edit
      it under `<dataDir>/connectors/<id>/manifest.yaml`

## Iterating

When the user reports a bug ("the list_my_issues tool returned 0 rows"):

1. Open the page they were on (they can navigate, you can suggest URLs).
2. Use the browser extension's DOM inspector (or `mcp__chrome__` if
   available) to find a stable selector.
3. Edit the script body in the manifest. Bump `version` (patch bump).
4. Tell the user to either close + reopen the Settings panel
   ("Refresh" in Connectors), or just re-invoke the tool — the
   extension picks up the new manifest on next call.

## Limits and gotchas

- Browser scripts cannot use Chrome extension APIs (`chrome.*`) — only
  page-context globals.
- For sites that virtualise lists (react-window, ag-grid), scroll
  programmatically inside the script before scraping; otherwise you
  only see what's currently in the DOM.
- For strict-CSP sites (Teams, github.com), pure `eval` is blocked.
  Use `runner: isolated` (which the extension's MV3 sandbox enables)
  but you lose access to `window` globals (MSAL tokens, etc.) — stick
  to pure DOM extraction.
- If a connector breaks after a site redesign, only the YAML's
  `script` body needs to change. Bump version, save, retry — the
  registry-based update path is for connectors that came from a
  shared `forge-connectors` repo.
