Skip to content
OpenComputer
Concepts

Connected services

Call Google services, GitHub, and Linear with a credential the platform holds and refreshes

Some credentials cannot be a secret you set. An OAuth token is short-lived, belongs to a person rather than to your project, and has to be refreshed. So the platform holds it: someone connects their account once, and your agent asks for a service by name instead of a URL and a header.

The access token never enters your runtime — there is nothing to log, leak, or hand to a model.

This is the other half of declared connections. Use defineConnection when you hold the credential and it is a static secret. Use callService when the platform holds it and has to refresh it.

1. Connect an account

Do this first — the code below returns 404 until an account exists.

Each account is connected under a label, which is how your agent addresses it. Use the CLI, or the API directly if you are scripting.

opencomputer connection add gmail --alias support

Both return a link. Whoever opens it consents with their own account and it is connected under that label — they never sign in to OpenComputer, and no credential passes through your side.

The CLI then waits and prints Connected once the account is authorized, so you know it worked. It does not open a browser: the account often belongs to somebody else, and this runs on servers as readily as on a laptop. Send them the link and leave it waiting, or press Ctrl-C — the connection is unaffected.

For minting several links at once, --no-wait prints the link and exits. --json does the same and is the one to script against.

Omitting the label connects it as default, which is what the examples below assume. Calls do not need a label when exactly one eligible account is connected. When several accounts are eligible, pass the label to select one; otherwise the call returns 409 connection_alias_required and lists them.

The provider segment in the URL is the grant, not the service — google for gmail, calendar, drive and sheets; github for GitHub; and linear for Linear.

connection add remains an alias for connection connect. If consent was revoked or the connected account must change, start a fresh OAuth flow without changing the logical connection or its project attachments:

opencomputer connection reconnect support --service gmail

Listing what is connected:

opencomputer connection list
# support            google  connected  [email protected]            3f9c1e77-...
# label              provider  status   account                    connection id

2. Attach the account to a project

By default a connected account is personal: calls resolve it from the person who started the session. That is useful in the playground, but channel sessions such as Slack have a channel principal rather than the project owner’s user identity.

Attach a connected account when it should be the project’s shared service credential:

opencomputer connection attach default --service linear --project current
opencomputer connection list --project current

The attachment applies to the whole project, including all of its agents and environments. An agent can use it only when its deployed code declares the service with useService(). The session principal is still retained as the actor for session ownership and audit; it no longer chooses the credential.

Detach removes only the project grant. It does not disconnect the underlying OAuth account:

opencomputer connection detach default --service linear --project current

Removing the connection itself also removes its project attachments. Preview inheritance follows the project’s attachment; a preview does not create a second OAuth connection.

A connection that is not yet connected is re-checked with the provider each time it is listed, so pending means what it says. A connected account is not re-checked: if someone revokes access at the provider, it keeps reading connected until the next call to it fails. Treat connected as “was working”, not “is working”.

Disconnecting one. The CLI takes the label or the id; the API takes the id from the listing above, and the service rather than the provider. If a label is ever ambiguous the CLI refuses rather than guessing — --service <name> picks one.

opencomputer connection remove support

Every CLI command takes --json if you want to parse its output.

3. Declare the service

import { useService, useTool } from "@opencomputer/agent";
import { unread } from "./tools/mail.js";

export default function Agent() {
  useService("gmail");
  useTool(unread);
  return "Answer questions about the triage queue.";
}

useService takes a literal string, read out of your source at build time. It puts the service in the capability manifest, so what your agent can reach is reviewable without running it, and it asks the deployment for that grant — without it, listServices() returns nothing.

ServiceReachesGrant
gmailgmail.googleapis.comGoogle
calendarwww.googleapis.comGoogle
drivewww.googleapis.comGoogle
sheetswww.googleapis.comGoogle
githubGitHub’s APIGitHub
linearapi.linear.app/graphqlLinear

The Google services share one grant; GitHub and Linear each have their own. A name outside that list fails the build rather than the request.

There are two ways to reach GitHub, and they are for different jobs. Use callService({ service: "github" }) to call the REST API on an account someone connected. Use a GitHub App connection when the agent needs to run git and gh itself — that one injects a token into the sandbox rather than proxying requests.

4. Call it from a tool

useService goes in the agent; callService goes in a tool. An agent renders synchronously and cannot wait for a request.

import { callService, defineTool } from "@opencomputer/agent";

export const unread = defineTool({
  name: "unread_reports",
  description: "Count unread messages labelled triage",
  async run({ signal }) {
    const response = await callService({
      service: "gmail",
      path: "/gmail/v1/users/me/messages?q=label:triage+is:unread",
      signal,
    });
    if (!response.ok) {
      throw new Error(`gmail: ${response.status} ${await response.text()}`);
    }
    const { messages = [] } = (await response.json()) as {
      messages?: unknown[];
    };
    return { waiting: messages.length };
  },
});

Note what that counts. Gmail returns at most 100 message ids per page, so messages.length is the size of the first page, not the total — real counting means following nextPageToken. Most collection APIs behave this way.

callService returns the service’s own response — status, headers and body — as fetch would, so a 403 for a missing scope stays distinguishable from a 404 for a deleted message. Check response.ok; a model told only “it failed” will retry forever.

Writes take the same shape:

await callService({
  service: "gmail",
  label: "support",
  method: "POST",
  path: "/gmail/v1/users/me/messages/abc/modify",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ removeLabelIds: ["UNREAD"] }),
  signal,
});

Linear uses one GraphQL endpoint. Send a JSON GraphQL document with POST:

const response = await callService({
  service: "linear",
  method: "POST",
  path: "/graphql",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    query: `query Viewer { viewer { id name email } }`,
  }),
});
const result = (await response.json()) as {
  data?: unknown;
  errors?: Array<{ message: string }>;
};
if (!response.ok || result.errors?.length) {
  throw new Error(result.errors?.[0]?.message ?? `linear: ${response.status}`);
}

Linear may report GraphQL errors in a successful HTTP response, so check both response.ok and errors. The Linear connection accepts only POST /graphql; other paths and methods fail at the broker.

Linear in sessions started from Linear

Which credential a call uses follows an explicit order. A session started from a Linear agent connection always uses that connection’s own Linear app. Otherwise, a project-attached Linear account wins; when the project has no Linear attachment, OpenComputer falls back to an eligible account owned by the session principal. In a session started from a Linear agent connection, the session belongs to the connection, and service: "linear" resolves to that connection’s own Linear app: the call acts as the agent, under the name people delegate to, with the app’s token attached by the platform.

  • No person’s connected Linear account is used in those sessions, and there is no fallback to one; a label is ignored.
  • The deployment must declare useService("linear").
  • Only POST /graphql is accepted, as everywhere.

The agent’s code is the same in both kinds of session.

5. Several accounts

Accounts are connected and disconnected long after the artifact is built, so ask for them rather than naming them in source.

export const sweep = defineTool({
  name: "sweep_mailboxes",
  description: "Count unread mail in every connected mailbox",
  async run({ signal }) {
    const mailboxes = await listServices({ provider: "google", signal });
    const counts: Record<string, number> = {};

    for (const mailbox of mailboxes) {
      const response = await callService({
        service: "gmail",
        label: mailbox.label,
        path: "/gmail/v1/users/me/messages?q=is:unread",
        signal,
      });
      const { messages = [] } = (await response.json()) as {
        messages?: unknown[];
      };
      counts[mailbox.displayName ?? mailbox.label] = messages.length;
    }
    return counts;
  },
});
FieldWhat it is
idThe connection’s id, for the disconnect route
labelThe alias it was connected under. Pass this as label.
displayNameWho the account belongs to, e.g. the mailbox address
providergoogle, github, or linear
scopesWhat was granted at consent
statusconnected accounts are usable

Only connected accounts are returned by default; pass connectedOnly: false for pending ones too. Connecting an account needs no redeploy — the next run sees it, and a consent finished seconds ago is reconciled before the list is answered.

Limits

A proxied request is not an unrestricted fetch.

MethodsGET, POST, PUT, PATCH, DELETE; Linear is POST only
Request headersOnly accept, content-type, if-match, if-none-match
Request body4 MB
Response body4 MB
Time30 seconds

Other request headers are dropped, not rejected. The request is still sent without them, so an API that changes behaviour on a header you set will act as though you never set it. There is no error to catch.

Authentication headers are dropped for the reason they are on a declared connection: the credential is the platform’s to attach, and a request that could overwrite it could also send it elsewhere.

Both size limits raise an error rather than truncating, so an unexpectedly large response fails loudly instead of arriving half-parsed. Page through large collections rather than asking for them in one call.

When something fails

You seeIt meansDo this
404 connection_not_foundNo eligible connected account exists, or no account matches the requested labelopencomputer connection list to inspect the accounts, then connect one or correct the label
409 connection_alias_requiredSeveral accounts connected, no label givenPass label; the message lists them
403 from the serviceThe consent is narrower than the call needsReconnect the account with wider access
listServices() returns []The service is not declaredAdd useService("gmail")
A connection stays pendingNobody opened the link, or it expiredRun connection reconnect and send the new link
It works in the playground but not SlackThe account is personal, so the Slack principal cannot resolve itAttach it with connection attach <alias> --service <name> --project current
A connected account returns 401Access was revoked at the provider; the stored status has not caught upRun connection reconnect with the same label
A header you set is ignoredIt is not in the allowlist aboveNothing — it cannot be sent
Throws managed connections are unavailableNo connection layerYou are running outside the platform

A 403 cannot be fixed from the agent. What a request may do is fixed by the consent that created the connection; read scopes from listServices() to see what was actually granted.

What this does not do

The platform does not proxy arbitrary hosts. A service reaches its own API and nothing else — for anything outside the table above, declare an HTTP connection instead.