> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.astropods.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.astropods.com/_mcp/server.

# Govern agent access with Lumos

[Lumos](https://www.lumos.com) manages entitlements: who may have access to which application, with requests, approvals, time limits, and periodic reviews. Connect it to Astropods and it manages the membership of your [audiences](/audiences), so agent access follows the same approval path as the rest of your stack.

Lumos never sits in the request path. It writes membership ahead of time, and Astropods decides each request on its own.

```mermaid
flowchart LR
    subgraph grant["Grant time"]
        A[Someone requests<br />an audience] --> B[Your approval<br />policy in Lumos]
        B --> C[Connector] --> D[Astropods<br />audience members]
    end
    subgraph request["Request time"]
        E[User opens<br />the agent] --> F[Astropods checks<br />the audience]
    end
    D -.-> F
```

This page covers both halves: creating the credential Lumos authenticates with, then building the connector that carries membership across. The end state is a connector that reads your audiences into the Lumos catalog and writes membership when an access request is approved.

## Before you start

* An audience for each group you want to govern. Create them first, because Lumos manages who is on a list rather than creating the lists. See [Audiences](/audiences).
* The account administrator or maintainer role, which is what lets you create an OAuth app. Organization and personal accounts both support them.
* Lumos's [Connector SDK](https://developers.lumos.com/docs/connector-sdk-1), and a Python environment to run it in. The connector runs in the Lumos cloud or on your own infrastructure.

```bash
pip install "connector-py[dev]"
```

## How the two models line up

Lumos uses the word "account" for a person's identity inside an application. Astropods uses it for the tenant. This page always means the tenant.

| Lumos concept        | Astropods                     | Identifier the connector sends |
| -------------------- | ----------------------------- | ------------------------------ |
| Application instance | One Astropods account         | The account slug               |
| Account              | A person's Astropods identity | Their user ID                  |
| Resource             | An audience                   | The audience ID                |
| Entitlement          | Membership of that audience   | The fixed ID `member`          |

Astropods has no second entitlement kind, so the connector synthesizes one `member` entitlement per audience rather than reading a list of them. Agents are not resources, so deploying or deleting one never changes the Lumos catalog: only audiences are governed, and the catalog stays stable while your agents change.

An OAuth app belongs to one Astropods account, so configure one Lumos application instance per account.

---

## Create the OAuth app

An OAuth app is a credential that belongs to the account rather than to a person, so it keeps working after its creator's own access changes.

#### Open the OAuth apps page

For an organization, go to **Settings**, then your organization, then **OAuth Apps**. For a personal account, go to **Settings** > **OAuth Apps**.

#### Create the app

Name it for the integration, such as `lumos`, and select its scopes. See [Scopes](#scopes) for the three a membership sync needs.

#### Copy the client ID and secret

The secret is shown once. Store it wherever your connector reads its configuration, because Astropods cannot show it again.

An app holds up to five secrets, and none of them expire. To rotate without downtime, add a second secret, move the connector onto it, then revoke the first. Revoking the last secret is refused, so rotation never leaves an app with no way in. Each secret shows when it was last used, which is how you tell which one the connector is still on.

Deleting the app revokes its access at once, including tokens that have not expired yet.

## Get an access token

Exchange the client ID and secret for an access token using the OAuth 2.0 client credentials grant:

```bash
curl -X POST https://login.astropods.com/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d client_id=$ASTRO_CLIENT_ID \
  -d client_secret=$ASTRO_CLIENT_SECRET
```

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600
}
```

Send the token on every request as `Authorization: Bearer <access_token>`, and request a new one when it expires. The API is at `https://astropods.com/api/v1`.

### Scopes

An app holds the scopes you select, and Astropods reads them from the app on every request. A scope change therefore applies to the next call rather than at the next expiry.

| Scope                     | Allows                                                                               |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `member:read`             | Read the account's member list, which is the directory you correlate people against. |
| `audience:read`           | Read audiences and their membership.                                                 |
| `audience:manage_members` | Add and remove audience members.                                                     |

Select only what the connector uses. Reading people and reading audiences are separate scopes, and a membership sync needs both, because it correlates people from the member list before it writes to an audience.

A scope applies to every audience in the account, not to particular ones. To stop a connector managing membership, remove `audience:manage_members` from the app. An app with no scopes still authenticates and is refused everywhere, which suspends an integration without destroying its credential.

A call that needs a scope the app does not hold returns `403`, because the resource exists and the credential is the thing that falls short.

## Check the credential by hand

Before building anything, confirm the app can read and write. `GET /api/v1/me` returns the account slug every other path contains, along with the app's own scopes:

```json
{
  "app": { "id": "...", "client_id": "...", "name": "lumos", "scopes": ["member:read", "audience:read", "audience:manage_members"] },
  "accounts": [{ "id": "...", "name": "acme", "type": "organization", "display_name": "Acme" }]
}
```

An app is bound to one account, so `accounts` holds exactly one entry and `accounts[0].name` is the slug.

Then assign someone:

```bash
curl -X POST https://astropods.com/api/v1/accounts/acme/audiences/aud_123/members \
  -H "Authorization: Bearer $ASTRO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"user_id": "user_01H...", "external_id": "lumos-assignment-42"}'
```

`external_id` is the assignment's identifier in Lumos. It is optional, and Astropods returns it on every read of that membership, so a review can trace a grant back to the request that produced it. One `external_id` names one membership: reusing it for a different person or audience returns `409`.

| Response | Meaning                                                                                   | What the connector should do                                                       |
| -------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `200`    | The person is a member. A replayed assignment returns `200` too.                          | Treat a retry after a timeout as normal traffic.                                   |
| `400`    | Neither `user_id` nor `account` was sent.                                                 | Fail.                                                                              |
| `403`    | The app lacks the scope the route needs.                                                  | Fail, and surface the scope. Nothing in Lumos can grant it.                        |
| `404`    | The `account` handle matches nobody on Astropods.                                         | Fail the assignment.                                                               |
| `409`    | That person has no Astropods user yet, or `external_id` already names another membership. | Retry the first on the next sync. Fail the second, because retrying cannot fix it. |
| `422`    | `user_id` holds an email address instead of a user ID.                                    | Fix the mapping: correlate on the handle, write the ID.                            |

---

## Scaffold the connector

```bash
connector scaffold astro-connector astro_connector \
  --author-name "Your Name" --author-email "you@example.com"
```

Five of the generated files carry this integration:

| File                                            | What you put in it                                             |
| ----------------------------------------------- | -------------------------------------------------------------- |
| `constants.py`                                  | The API base URL, the token URL, and the credential ID enum    |
| `enums.py`                                      | The `audience` resource type and the `member` entitlement type |
| `auth.py`                                       | The client-credentials credential                              |
| `client.py`                                     | The Astropods HTTP client                                      |
| `capabilities_read.py`, `capabilities_write.py` | The capability bodies                                          |

## Declare the credential

Astropods issues OAuth 2.0 client credentials, so the connector asks Lumos for a client ID and secret and exchanges them itself:

**`astro_connector/constants.py`**

```python title="astro_connector/constants.py"
class AstroConnectorCredentialId(str, Enum):
    """IDs of the credentials this connector accepts."""
    OAUTH_CLIENT_CREDENTIALS = "astro_oauth_client_credentials"

API_BASE_URL = "https://astropods.com/api/v1"
TOKEN_URL = "https://login.astropods.com/oauth2/token"
```

**`astro_connector/auth.py`**

```python title="astro_connector/auth.py"
AstroCredentialsConfig = [
    CredentialConfig(
        id=AstroConnectorCredentialId.OAUTH_CLIENT_CREDENTIALS,
        name="Astropods OAuth app",
        type=AuthModel.OAUTH_CLIENT_CREDENTIALS,
        description=OAUTH_DESCRIPTION,
        oauth_settings=OAuthConfig(
            flow_type=OAuthFlowType.CLIENT_CREDENTIALS,
            token_url=TOKEN_URL,
            scopes=["member:read", "audience:read", "audience:manage_members"],
        ),
    ),
]
```

The credential ID is part of the connector's public contract. Renaming it later invalidates every existing connection, so choose it once.

Lumos maps scopes to capabilities in `oauth_settings`, which is what stops a connector that never writes from holding a write scope. See Lumos's [authorization reference](https://developers.lumos.com/reference/authorization-and-authentication) for the mapping syntax.

## Write the client

The SDK hands each capability an `OAuthClientCredential` carrying an `access_token`, so the client sends it as a bearer token and does not handle the exchange itself:

**`astro_connector/client.py`**

```python title="astro_connector/client.py"
class AstroClient:
    def __init__(self, credential: OAuthClientCredential):
        self._headers = {"Authorization": f"Bearer {credential.access_token}"}
        self._account: str | None = None

    async def account(self) -> str:
        """The account slug every other path contains."""
        if self._account is None:
            me = await self._get("/me")
            self._account = me["accounts"][0]["name"]
        return self._account
```

## Register the capabilities

**`astro_connector/integration.py`**

```python title="astro_connector/integration.py"
integration.register_capabilities(
    {
        StandardCapabilityName.VALIDATE_CREDENTIALS: capabilities_read.validate_credentials,
        StandardCapabilityName.LIST_ACCOUNTS: capabilities_read.list_accounts,
        StandardCapabilityName.LIST_RESOURCES: capabilities_read.list_resources,
        StandardCapabilityName.LIST_ENTITLEMENTS: capabilities_read.list_entitlements,
        StandardCapabilityName.FIND_ENTITLEMENT_ASSOCIATIONS: capabilities_read.find_entitlement_associations,
        StandardCapabilityName.ASSIGN_ENTITLEMENT: capabilities_write.assign_entitlement,
        StandardCapabilityName.UNASSIGN_ENTITLEMENT: capabilities_write.unassign_entitlement,
    }
)
```

Each one is backed by a single endpoint:

| Capability                      | Endpoint                                                               |
| ------------------------------- | ---------------------------------------------------------------------- |
| `validate_credentials`          | `GET /me`                                                              |
| `list_accounts`                 | `GET /accounts/{account}/members`                                      |
| `list_resources`                | `GET /accounts/{account}/audiences`                                    |
| `list_entitlements`             | None. Synthesize one `member` entitlement per audience.                |
| `find_entitlement_associations` | `GET /accounts/{account}/audience-members`                             |
| `assign_entitlement`            | `POST /accounts/{account}/audiences/{audience_id}/members`             |
| `unassign_entitlement`          | `DELETE /accounts/{account}/audiences/{audience_id}/members/{user_id}` |

## Implement the read capabilities

### Validate the credential

`GET /me` is the cheapest authenticated call, and it returns the app's own scopes. Check them here so a missing scope surfaces when someone connects the app rather than halfway through the first sync.

**`astro_connector/capabilities_read.py`**

```python title="astro_connector/capabilities_read.py"
async def validate_credentials(
    args: ValidateCredentialsRequest,
) -> ValidateCredentialsResponse:
    me = await client(args).get("/me")
    missing = REQUIRED_SCOPES - set(me["app"]["scopes"])
    ...
```

### List accounts

The member list is the directory you correlate people against. Map each member to `FoundAccountData`:

| `FoundAccountData`        | Astropods field                              |
| ------------------------- | -------------------------------------------- |
| `integration_specific_id` | `user_id`                                    |
| `username`                | `username`, the person's Astropods handle    |
| `email`                   | `email`, when present                        |
| `user_status`             | `ACTIVE`, or `PENDING` for an invited member |
| `account_type`            | `user`                                       |

Correlate people to your directory by handle, not by email. Astropods returns `email` only for a member who has not set up a profile yet, so it is absent for most of the list. Write `user_id` once you have matched someone, because handles change and IDs do not. If you hold only a handle, send `account` instead of `user_id` and Astropods resolves it.

### List resources and entitlements

`GET /accounts/{account}/audiences` returns the lists. Map each to `FoundResourceData` with `integration_specific_id` set to the audience ID, `label` to its name, and `resource_type` to `audience`. The response also carries an `arn` and a `member_count`, which are useful in `extra_data` for a review.

`list_entitlements` calls nothing. For each audience in the request, return one entitlement whose ID is `member`, typed `member`. An audience has no other kind of membership, so a second entitlement would describe nothing.

### Find associations

`GET /accounts/{account}/audience-members` is a flat collection of every (audience, person) pair in the account, which is exactly the shape this capability returns:

```python
FoundEntitlementAssociation(
    account_id=row["user_id"],
    integration_specific_entitlement_id="member",
    integration_specific_resource_id=row["audience_id"],
)
```

Read the flat collection rather than each audience's own member list. Per-audience reads cost one call per audience on every sync, and this endpoint exists to collapse them into one paged walk.

Include expired rows. Astropods marks them with `expired: true` instead of hiding them, so a review can show what lapsed.

## Implement the write capabilities

An assignment is one `POST` per person. No endpoint accepts a list of members, because retrying, overlapping calls are only safe against per-person writes.

**`astro_connector/capabilities_write.py`**

```python title="astro_connector/capabilities_write.py"
async def assign_entitlement(
    args: AssignEntitlementRequest,
) -> AssignEntitlementResponse:
    request = args.request
    await client(args).post(
        f"/accounts/{account}/audiences/{request.resource_integration_specific_id}/members",
        json={
            "user_id": request.account_integration_specific_id,
            "external_id": external_id(args),
        },
    )
    return AssignEntitlementResponse(response=AssignedEntitlement(assigned=True))
```

Send `expires_at` as well when Lumos grants time-bound access. See [Time limits](#time-limits).

`unassign_entitlement` is the matching `DELETE`, and it removes only the row your app wrote. A membership belongs to the writer that created it, so an identical grant an administrator added by hand survives your removal. To end access entirely, remove it in both places.

Every write is attributed to the app, so the audit log names the app rather than the person who created the credential.

## Handle pagination

The audience endpoints take `limit` and an opaque `cursor`, and return `next_cursor` until the last page, so a review reads the whole set without gaps. Carry the Astropods cursor in the Lumos page token:

```python
page=Page(token=body.get("next_cursor"), size=page_size) if body.get("next_cursor") else None
```

A `None` token is how Lumos knows to stop calling, so return it as soon as `next_cursor` is absent. When one capability walks more than one upstream cursor, use the SDK's [pagination helpers](https://developers.lumos.com/reference/pagination) instead of a bare token.

The member list is not paged and returns every member in one response.

---

## Time limits

Set `expires_at` when Lumos grants time-bound access and Astropods enforces it: an expired membership stops granting access within a minute, whether or not Lumos calls back to remove it. Lumos removing the assignment stays the normal path.

An expired membership still appears when the connector reads membership, with `expired` set to `true`, so a review shows what lapsed rather than hiding it.

## Slack identity

An audience is a list of people, so a Slack request has to be matched to a person before the list applies. People link their own Slack account from their settings.

## What Lumos does not manage

* **Creating and deleting people.** Your identity provider owns who exists. Deprovision by removing the audience membership, not by editing an Astropods user. Astropods never creates users, so invite people through your identity provider as usual.
* **Creating audiences.** Create the lists in Astropods, then govern their membership from Lumos.
* **Linking Slack accounts.** Each person links their own, so a connector never writes it.
* **Platform permissions.** Audiences control who can talk to an agent. Who may deploy or reconfigure one is a separate setting, covered in [Access control](/access-control).
* **Usage reporting.** Astropods does not report a last-used time per person and agent, so Lumos cannot flag unused agent access during a review.

## Next steps

#### [Audiences](/audiences)

Create the lists Lumos will manage.

#### [API reference](/api-reference)

The endpoints behind each capability.