> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# GitHub connector

> Install the Exo GitHub App, sync repositories into the graph, trigger a manual re-sync, and disconnect cleanly.

The GitHub connector pulls repository content into your organization's knowledge graph and keeps it current. It is the only provider Exo manages in v1.

## Before you start

* A key with the `write` scope. See [Scopes](/scopes).
* Permission to install a GitHub App on the account or organization that owns the repositories.
* A browser, for the install step. The connect flow returns a URL you have to send a human to.

## The flow

Connecting is three calls with a browser redirect in the middle.

```mermaid theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
flowchart LR
  A["POST /v1/connectors/github"] --> B["Send user to<br/>authorizationUrl"]
  B --> C["GitHub App install"]
  C --> D["Exo finalizes<br/>the installation"]
  D --> E["GET /v1/connectors<br/>status: connected"]
```

## Connect

<Steps>
  <Step title="Begin the connect flow">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl -X POST https://api.get-exo.com/v1/connectors/github \
        -H "X-Exo-API-Key: $EXO_KEY"
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, requests

      res = requests.post(
          "https://api.get-exo.com/v1/connectors/github",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
      )
      print(res.json()["authorizationUrl"])
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const res = await fetch("https://api.get-exo.com/v1/connectors/github", {
        method: "POST",
        headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
      });
      const { authorizationUrl } = await res.json();
      ```
    </CodeGroup>

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "provider": "github",
      "authorizationUrl": "https://github.com/apps/exo/installations/new?state=eyJhbGciOi..."
    }
    ```

    The `state` parameter is a signed, short-lived token that binds the resulting installation back to the authenticated caller. Do not strip it, do not cache the URL, and do not hand the same URL to two different people.
  </Step>

  <Step title="Send the user to that URL">
    They pick which repositories the app can see. GitHub then calls Exo back and the installation is finalized.

    This step needs a real browser and a real human with install rights. There is no headless path.
  </Step>

  <Step title="Confirm and see the repositories">
    <CodeGroup>
      ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      curl https://api.get-exo.com/v1/connectors \
        -H "X-Exo-API-Key: $EXO_KEY"
      ```

      ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      import os, requests

      res = requests.get(
          "https://api.get-exo.com/v1/connectors",
          headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
      )
      for c in res.json()["data"]:
          print(c["provider"], c["status"], c["id"])
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
      const res = await fetch("https://api.get-exo.com/v1/connectors", {
        headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
      });
      const { data } = await res.json();
      ```
    </CodeGroup>

    ```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
    {
      "data": [
        {
          "id": "58201947",
          "provider": "github",
          "status": "connected",
          "repos": [
            {
              "repoId": 771204558,
              "fullName": "acme/platform",
              "lastSyncedAt": "2026-07-14T08:31:12Z",
              "itemsSynced": 412
            },
            {
              "repoId": 771204893,
              "fullName": "acme/docs",
              "lastSyncedAt": null,
              "itemsSynced": null
            }
          ]
        }
      ]
    }
    ```

    `id` is the GitHub installation id rendered as a string. Keep it: every other connector call takes it in the path.
  </Step>
</Steps>

<Note>
  Before you have ever connected GitHub, `GET /v1/connectors` returns a **provider stub**: one entry with `provider: "github"`, `status: "not_connected"` and a **null `id`**. It exists so a client can discover the provider and offer a connect button without special-casing an empty list.

  Code that reads `data[0].id` unconditionally breaks on a fresh organization. Check `status` first.
</Note>

## Keep it synced

Repositories sync on their own. `POST /v1/connectors/{id}/sync` forces a pass now, which is what you want after adding repositories to the installation or after a long gap.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/connectors/58201947/sync \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.post(
      "https://api.get-exo.com/v1/connectors/58201947/sync",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  print(res.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch(
    "https://api.get-exo.com/v1/connectors/58201947/sync",
    { method: "POST", headers: { "X-Exo-API-Key": process.env.EXO_KEY! } },
  );
  console.log(await res.json());
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "connectorId": "58201947",
  "provider": "github",
  "jobsEnqueued": 2,
  "jobIds": ["a17c3e90-4d52-4c81-b6f3-18e70a2c95db", "b4d20f65-9c07-4e3a-8d15-72fa60c4831e"]
}
```

One backfill job per repository in the installation. Track them like any other job with `GET /v1/jobs/{id}`.

<Tip>
  This call is safe to repeat. The worker dedups content at ingest, and a backfill already in flight for a repository is absorbed by the queue's own dedup, so a nervous retry does not double-sync anything. `jobsEnqueued` reports only the jobs that were newly created, so a repeat call can legitimately return `0`.
</Tip>

## Disconnect

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X DELETE https://api.get-exo.com/v1/connectors/58201947 \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os, requests

  res = requests.delete(
      "https://api.get-exo.com/v1/connectors/58201947",
      headers={"X-Exo-API-Key": os.environ["EXO_KEY"]},
  )
  print(res.json())
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const res = await fetch("https://api.get-exo.com/v1/connectors/58201947", {
    method: "DELETE",
    headers: { "X-Exo-API-Key": process.env.EXO_KEY! },
  });
  console.log(await res.json());
  ```
</CodeGroup>

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "58201947",
  "provider": "github",
  "status": "disconnected",
  "revoked": true
}
```

Disconnecting marks the installation disconnected and stops repositories syncing. It is a tombstone status, not a physical delete, and it is idempotent: a second disconnect of a connector you own still succeeds.

<Warning>
  Disconnecting stops future syncing. It does **not** remove content already in your graph. To remove that too, forget the nodes individually with `DELETE /v1/graph/nodes/{id}`, or run a scoped purge. See [Forgetting and portability](/guides/forgetting).
</Warning>

Reconnect by beginning the connect flow again. You get a new installation id.

## When it goes wrong

<AccordionGroup>
  <Accordion title="422 on POST /v1/connectors/{provider}" icon="circle-question">
    The provider is not supported. v1 manages `github` and nothing else. The problem body names the supported providers. See [`validation_error`](/errors/validation_error).
  </Accordion>

  <Accordion title="404 on sync or disconnect" icon="circle-question">
    No connector with that id exists in this organization. Two usual causes: you passed the provider name where the id belongs, or you used an id from a different organization. Read the id from `GET /v1/connectors`. See [`connector_not_found`](/errors/connector_not_found).

    Do not confuse this with the connector **status** `not_connected`, which is what the provider stub reports before you have ever connected. That is a normal state, not an error.
  </Accordion>

  <Accordion title="409 on sync" icon="circle-question">
    The connector is not in a syncable state, which normally means it has been disconnected. Reconnect first. A suspended installation, usually a GitHub-side billing or permission problem, also lands here. See [`connector_not_syncable`](/errors/connector_not_syncable).
  </Accordion>

  <Accordion title="Connected, but repos is empty or itemsSynced stays null" icon="circle-question">
    The install granted access to no repositories, or the first backfill has not run. Check the App's repository access on GitHub, then trigger `POST /v1/connectors/{id}/sync` and watch the returned job ids.
  </Accordion>
</AccordionGroup>

## Next

<Columns cols={2}>
  <Card title="Reacting to change" icon="radio" href="/guides/reacting-to-change">
    Subscribe to `job.completed` instead of polling every sync.
  </Card>

  <Card title="The knowledge graph" icon="git-branch" href="/concepts/knowledge-graph">
    What a synced repository becomes once it is in the graph.
  </Card>
</Columns>
