Docs

Build a GitHub agent

In this example, we are going to build a GitHub analytics product that keeps its dashboard in your cloud and runs each customer's GitHub integration in an isolated customer deployment.

The hosted dashboard owns user accounts, organizations, charts, and historical metrics. A small Worker runs for each customer and owns the GitHub integration: it stores the repository configuration, calls the GitHub API, classifies pull requests, and returns the results the dashboard needs.

The hosted dashboard configures and invokes a GitHub Worker through Commands. The Worker stores integrations in Vault. The browser can also fetch detailed pull-request data from the Worker's HTTPS endpoint.

This split keeps GitHub configuration and execution out of the shared SaaS backend. It is useful when an integration needs customer-specific isolation, cloud identity, private network access, or separate operational ownership. You deploy only that small piece—not the whole product—to a customer cloud, dedicated account, Kubernetes cluster, or another isolated environment.

We will follow one organization from setup to its first repository analysis:

  1. The dashboard creates a deployment group for the organization.
  2. The customer deploys the Worker.
  3. The dashboard sends the repository configuration to that deployment.
  4. A workflow invokes analyze-repository and stores the returned metrics.
  5. The browser requests detailed pull-request data from the deployment's HTTPS endpoint.

Choose what belongs in each place

The hosted application remains an ordinary Next.js product. It uses Postgres for product data and the Alien Platform API to manage customer deployments.

Only the GitHub-specific execution moves into the customer deployment:

  • a Worker named agent
  • a Vault named integrations
  • three Commands for configuration and analysis
  • one HTTPS endpoint for detailed pull-request results

This keeps the remote piece small. Authentication, billing, organization membership, charts, and durable metric history do not need to be duplicated for every customer.

Describe the remote piece

The remote package has its own alien.ts:

packages/remote-agent/alien.ts
const integrations = new alien.Vault("integrations").build()

const agent = new alien.Worker("agent")
  .code({ type: "source", src: "./", toolchain: { type: "typescript" } })
  .link(integrations)
  .memoryMb(512)
  .commandsEnabled(true)
  .publicEndpoint("api")
  .permissions("execution")
  .build()

export default new alien.Stack("github-agent")
  .platforms(["aws", "gcp", "azure", "kubernetes"])
  .add(integrations, "frozen")
  .add(agent, "live")
  .permissions({ profiles: { execution: {} } })
  .build()

The two entry points serve different jobs:

  • Commands carry private product operations from the hosted backend. The Worker does not need an inbound admin port, VPN, or VPC peering for these calls.
  • The HTTPS endpoint serves pull-request data to the browser. It is public because the browser must be able to reach it.

Do not confuse “Commands need no inbound port” with “this Worker has no public endpoint.” This example intentionally uses both communication paths.

The Worker is live, so Alien can update its code during normal rollouts. Vault is frozen, so its infrastructure remains owned by customer setup. Linking Vault to the Worker and granting runtime permissions are separate from that lifecycle choice.

Create one deployment group per organization

The dashboard groups deployments by product organization. When an organization is first created, the backend creates an Alien deployment group and a token the customer can use for setup:

packages/dashboard/lib/deployment-groups.ts
const deploymentGroup = await alien.deploymentGroups.createDeploymentGroup({
  workspace: config.workspace,
  createDeploymentGroupRequest: {
    name,
    project: config.project,
    maxAgents: 10,
  },
})

const tokenResponse = await alien.deploymentGroups.createDeploymentGroupToken({
  workspace: config.workspace,
  id: deploymentGroup.id,
  createDeploymentGroupTokenRequest: {
    description: `Deployment token for ${organizationName}`,
  },
})

The hosted database stores the deployment group ID and token with the organization. Later, the dashboard lists only deployments in that group:

packages/dashboard/lib/alien.ts
const response = await alien.deployments.list({
  workspace: config.workspace,
  deploymentGroup: metadata.deploymentGroupId,
})

This is the multi-tenant join: the product organization points to its Alien deployment group, and each repository integration points to one deployment inside that group.

Deploy the Worker

The dashboard turns the deployment-group token into a setup link. The customer opens that link and chooses where the Worker should run.

1 · Release
alien release

Publishes a version. Nothing is deployed for a customer yet.

2 · Invite
alien onboard acme-corp

Creates a deployment link for that customer.

3 · Deploy

The customer opens the link and deploys into their environment.

The hosted dashboard now sees the new deployment in the organization's deployment group. The Worker and Vault run in the environment selected during setup; the rest of the product remains hosted.

For local development, start the remote package directly:

cd examples/github-agent/packages/remote-agent
alien dev

alien dev provides local Worker, Commands, and Vault implementations, so you can develop the remote package without deploying a cloud stack.

Store the GitHub integration in Vault

When a user adds a repository, the dashboard constructs an integration ID and invokes set-integration against the selected deployment:

packages/dashboard/app/api/integrations/route.ts
const integrationId = `github-${activeOrgId}-${owner}-${repo}`
  .toLowerCase()
  .replace(/[^a-z0-9-]/g, "-")

await invokeCommand(agentId, "set-integration", {
  integrationId,
  config: {
    owner,
    repo,
    token,
    baseUrl: baseUrl || undefined,
  },
})

The token is submitted to the hosted backend and sent through the Command to the selected deployment. The hosted product database does not persist it; it stores only repository metadata and whether a token was supplied.

Inside the Worker, the Command writes the configuration to Vault:

packages/remote-agent/src/commands.ts
command("set-integration", setIntegrationSchema, async ({ integrationId, config }) => {
  const normalized = normalizeConfig(config)
  await saveIntegrationConfig(integrationId, normalized)
  return { ok: true }
})
packages/remote-agent/src/integrations.ts
export async function saveIntegrationConfig(
  integrationId: string,
  config: IntegrationConfig,
) {
  const integrations = await vault("integrations")
  await integrations.set(integrationId, config)
}

This is more precise than saying the dashboard “never sees” the credential: it does receive it during setup. The important property in this example is that the hosted database does not retain it and later GitHub calls load it from Vault in the remote deployment.

Expose product operations as Commands

The Worker registers named operations rather than a generic remote shell:

packages/remote-agent/src/commands.ts
command("analyze-repository", integrationIdSchema, async ({ integrationId }) => {
  const config = await loadIntegrationConfig(integrationId)
  const pullRequests = await fetchPullRequests(config)
  const classified = classifyPullRequests(pullRequests)
  return computeMetrics(classified)
})

command("label-pull-requests", integrationIdSchema, async ({ integrationId }) => {
  const config = await loadIntegrationConfig(integrationId)
  const pullRequests = await fetchPullRequests(config)
  const openPullRequests = pullRequests.filter(pr => pr.state === "open")

  for (const { pr, classification } of classifyPullRequests(openPullRequests)) {
    await applyLabels(config, pr.number, [
      `size:${classification.size}`,
      `risk:${classification.risk}`,
    ])
  }

  return { labeled: openPullRequests.length }
})

The callable surface is visible in code and validated with Zod. Adding a new operation requires a reviewed code change and a rollout of the Worker.

Invoke the correct customer deployment

The hosted backend first resolves connection information for the deployment, then creates a Commands client:

packages/dashboard/lib/arc.ts
const info = await alien.deployments.getInfo({
  workspace: config.workspace,
  id: deploymentId,
})

const commands = new CommandsClient({
  managerUrl: info.arc?.url || config.alienApiUrl,
  deploymentId: info.arc?.deploymentId || deploymentId,
  token: config.alienToken,
})

const metrics = await commands.invoke("analyze-repository", {
  integrationId,
})

Alien delivers the named Command to that deployment and returns the handler result. The dashboard does not open a connection to the customer's network or receive the Vault binding.

Sync remote results into the hosted product

The dashboard runs a durable workflow that invokes analyze-repository and stores the returned aggregate metrics in its own database:

packages/dashboard/workflows/sync-metrics.ts
const metrics = await invokeCommand<AnalysisMetrics>(
  agentId,
  "analyze-repository",
  { integrationId },
)

const now = new Date()
await db.insert(metricsHistory).values({
  id: `metrics_${integrationId}_${now.getTime()}`,
  integrationId,
  totalPRs: metrics.totalPRs,
  avgTimeToFirstReviewHours: metrics.avgTimeToFirstReviewHours,
  avgMergeTimeHours: metrics.avgMergeTimeHours,
  reviewThroughputScore: metrics.reviewThroughputScore,
})

The remote Worker owns GitHub access and analysis. The hosted application owns historical product data and presentation. Only the returned metrics need to cross between them.

Serve detailed pull-request data over HTTPS

The Worker also exposes GET /prs. It loads the integration from Vault, calls GitHub, classifies the pull requests, and returns the detailed list:

packages/remote-agent/src/endpoints.ts
app.get("/prs", async c => {
  const integrationId = c.req.query("integrationId")
  if (!integrationId) {
    return c.json({ error: "integrationId is required" }, 400)
  }

  const config = await loadIntegrationConfig(integrationId)
  const pullRequests = await fetchPullRequests(config)
  const classified = classifyPullRequests(pullRequests)

  return c.json({ integrationId, pullRequests: classified })
})

The browser obtains the Worker's publicUrl from deployment state, verifies that it is an HTTPS URL, and fetches this route directly.

This endpoint is intentionally open in the example. A production version must authenticate the caller and authorize access to the requested integration. Commands solve private backend-to-deployment operations; they do not automatically secure public HTTP routes.

What you built

You built a product with a hosted control plane and one small remote component per customer:

  • Each organization owns a deployment group.
  • The customer chooses where its Worker and Vault run.
  • Repository configuration is stored in the deployment's Vault.
  • The hosted backend invokes explicit operations through Commands without opening an inbound admin port.
  • Aggregate metrics return to the hosted product database.
  • Detailed pull-request data is served separately over an HTTPS endpoint.

The point is not GitHub specifically. The same structure works for database connectors, internal search, security scanners, and agents that need credentials or network access you do not want to centralize in the hosted application.

Complete source: examples/github-agent.

Next: Commands, Vault, and Onboarding customers.

On this page