@gea-ai/app-sdk
v0.1.260923-alpha.1
Published
Server-side helpers for apps that use GEA OAuth or an organization API key.
Readme
@gea-ai/app-sdk
Server-side helpers for apps that use GEA OAuth or an organization API key.
Each app package has one client_id and one client secret. Installing it creates
an organization-owned app_resource; its id is the immutable installation ID.
Keep the client secret, authorization codes, refresh tokens, and access tokens in the app backend. Browser code should only receive the app's own session.
OAuth client
import { createOAuthClient } from "@gea-ai/app-sdk";
const gea = createOAuthClient({
baseUrl: process.env.GEA_BASE_URL!,
clientId: process.env.GEA_CLIENT_ID!,
clientSecret: process.env.GEA_CLIENT_SECRET!,
});The SDK requires HTTPS because OAuth client credentials and bearer tokens are
sent to this origin. Plain HTTP is accepted only for local development hosts
such as musegea.localhost, its tenant subdomains, localhost, and loopback.
User authorization
When a user opens an installed app from GEA, the launch URL includes the public
client_id, installation_id, and organization_id. Use the organization ID
as an untrusted selector, create a random state and PKCE verifier in the app
backend, then redirect the browser to GEA:
const authorizationUrl = gea.getAuthorizationUrl({
codeChallenge,
organizationId,
redirectUri: "https://app.example.com/oauth/callback",
scope: ["openid", "profile", "offline_access", "resources:read"],
state,
});GEA validates the selector against the OAuth client, active installation, and signed-in user's active membership. The selector does not grant access by itself. Direct authorization without an organization selector is not supported in this version.
After validating state in the callback, exchange the one-time code:
const token = await gea.exchangeAuthorizationCode({
code,
codeVerifier,
redirectUri: "https://app.example.com/oauth/callback",
});The response includes verified organization and installation metadata. It
also includes a refresh token when offline_access was granted:
const renewed = await gea.refreshUserToken({
refreshToken: token.refreshToken!,
});Refresh tokens rotate. Persist the replacement returned by every successful refresh and discard the previous value.
Revoke an access or refresh token when the app session ends or credentials are otherwise no longer needed:
await gea.revokeToken({
token: token.accessToken,
tokenTypeHint: "access_token",
});Tenant authorization
Use a tenant token only for app-level APIs that do not act as a user. An organization-local app omits an installation selector because GEA infers its owning organization:
const tenantToken = await gea.getTenantToken({
scope: ["organization:read", "resources:read"],
});A Marketplace app supplies the immutable installation ID:
const tenantToken = await gea.getTenantToken({
installationId: "019...",
scope: ["organization:read", "resources:read"],
});An organization slug is never accepted as the authorization boundary. Tenant tokens do not contain a user principal and cannot call user, membership, or workspace-member APIs.
ORPC client
Create an API client with either a user or tenant access token:
import { createClient } from "@gea-ai/app-sdk";
const api = createClient({
accessToken: token.accessToken,
baseUrl: process.env.GEA_BASE_URL!,
});Only routes that explicitly accept the token kind and scopes are available. GEA revalidates the OAuth client, package, installation, organization, membership for user tokens, and requested scopes on every request.
resources.list() returns active organization-owned resources
shared with all workspaces or at least one specific workspace. User-owned and
user-only resources are excluded.
Agent chats
Agent chat APIs require a user access token with agents:read,
agent_chats:read, and/or agent_chats:write. Tenant tokens cannot use these
scopes because every chat runs as the authorizing user in one explicit
workspace.
Create a workspace client with the organization slug from the token response and a workspace slug selected or configured by your app:
import { createWorkspaceClient } from "@gea-ai/app-sdk";
const gea = createWorkspaceClient({
accessToken: token.accessToken,
baseUrl: process.env.GEA_BASE_URL!,
organizationSlug: token.organization.slug,
workspaceSlug: appWorkspaceSlug,
});
const agents = await gea.agents.list();
const run = await gea.agentChats.start({
agentId: agents[0]!.id,
message: "Summarize the latest project context.",
});
const { chatId } = run;
const stream = await gea.agentChats.openStream({ chatId });
// Consume stream.body as an AI SDK UI message stream.
await gea.agentChats.sendMessage({
chatId,
message: "Turn that into three action items.",
});
const page = await gea.agentChats.messages.list({ chatId, limit: 50 });When an SDK-first Agent pauses on Tool approval, read the pending assistant message and submit decisions without echoing Tool input:
await gea.agentChats.respondToToolApprovals({
chatId,
messageId: pendingMessage.id,
responses: [{ id: pendingApproval.id, approved: true }],
});The same Chat methods also work with legacy Agents. Runtime selection is owned by the selected Agent's server-side metadata; Tool approval responses are accepted only for SDK-first Agent Worker chats.
Agent APIs reject organization-only RPC routes. The workspace slug must be present in the request route; GEA never selects a fallback workspace for a third-party app.
GEA generates persisted chat and message IDs. Read chatId from the result of
agentChats.start() and use it for later stream, message, and history calls.
start() and sendMessage() do not currently provide idempotent retry
semantics, so do not automatically retry either operation after an ambiguous
network failure. With the corresponding scopes, the app can read or continue
any root chat owned by the authorizing user in the selected organization and
workspace; the chat does not have to originate from that app. Chats created
through this API record the source installation in chat.metadata for
provenance only; it is not an authorization boundary.
openStream() exchanges the user token server-side for a short-lived,
chat-bound stream capability, then opens the public streaming endpoint with
that capability. The capability remains bound to that exact OAuth grant: GEA
rechecks the grant, its read scope, and the user's current workspace access
when the stream opens. Revoking the grant or removing workspace access makes an
already-issued capability unusable. Keep both the user token and stream
response in the app backend; relay only the data your own frontend needs. GEA
briefly waits for a new chat run to register its stream. If the response is 503 with
Retry-After, call openStream() again with the same chatId. A 204
response means the run finished before a stream consumer attached; call
agentChats.messages.list() with the same chatId to read the final messages.
The stream capability contains only opaque grant, installation, and chat
references. GEA resolves the user, organization, installation, and workspace
server-side when the stream opens. Message pages preserve Agent sender metadata
but redact the GEA user's internal ID and name. Use the OAuth pairwise sub and
the explicitly granted profile claims when your app needs user identity.
API-key Agent chats
Use createApiKeyWorkspaceClient for the API-key-only Agent surface. A user
key omits externalUserId and sends only its Bearer credential:
import { createApiKeyWorkspaceClient } from "@gea-ai/app-sdk";
const gea = createApiKeyWorkspaceClient({
apiKey: process.env.GEA_API_KEY!,
baseUrl: process.env.GEA_BASE_URL!,
organizationSlug: "acme",
workspaceSlug: "research",
});
const chatId = crypto.randomUUID();
const stream = await gea.agentChats.run({
agentId: "agent-019...",
chatId,
message: "Summarize this workspace.",
});
if (!stream.ok || !stream.body) {
throw new Error(`Agent Chat failed: ${stream.status}`);
}A team key must create a client for one directory identity. Set
externalUserId to the exact case-sensitive directory principalId /
externalId; the SDK sends it as x-user-id on every RPC request:
const gea = createApiKeyWorkspaceClient({
apiKey: process.env.GEA_TEAM_API_KEY!,
baseUrl: process.env.GEA_BASE_URL!,
externalUserId: customerDirectoryPrincipalId,
organizationSlug: "acme",
workspaceSlug: "research",
});The deployment must enable features.teamApiKeyIdentityDelegation. Team keys
are restricted to this Agent API and its three Agent scopes; hosted MCP and
general scoped ORPC routes accept personal keys only.
The client exposes agents.list(), agentChats.run(),
agentChats.openStream(), and agentChats.messages.list(). run() sends one
POST /api/chat request and returns its AI SDK UI-message stream directly.
Store both kinds of API key on the server. Do not relay a team key or let a
browser choose x-user-id independently of the authenticated customer user.
API-key Agent access uses the independent agents.read, agent_chats.read,
and agent_chats.write scopes; OAuth's colon-delimited scopes remain
unchanged. run() requires agent_chats.write; openStream() and persisted
message reads require agent_chats.read. Both direct HTTP requests send the
same API key and, for a team key, the same fixed x-user-id. GEA resolves that
identity and rechecks the Key, user, Chat ownership, and workspace access on
every request.
Do not automatically retry run() after an ambiguous result. A 503 stream
response may be retried by calling openStream() with the existing chatId;
after 204, read the final persisted messages with
agentChats.messages.list().
To attach files, call files.upload({ file, filename }). It sends one
multipart POST /api/files with the same credentials, requires
agent_chats.write, and returns the raw Response; a 201 body contains the
file id. Pass those ids as fileIds to run() together with a string
message:
const uploaded = await gea.files.upload({
file: pdfBlob,
filename: "report.pdf",
});
if (uploaded.status !== 201) {
throw new Error(`Upload failed: ${uploaded.status}`);
}
const { id } = await uploaded.json();
const stream = await gea.agentChats.run({
agentId: "agent-019...",
chatId,
fileIds: [id],
message: "Analyze this report.",
});Each file is at most 20 MiB and a run accepts at most 10 fileIds. A file can
be used once, only by the user who uploaded it, in the same workspace; an
unavailable id makes run() return 404 without starting a run.
AI SDK browser integration
The canonical step-by-step tutorial is Stream Agent responses in the GEA developer docs. The same core pattern is included here for SDK readers.
GEA returns an AI SDK UI message stream, but the GEA user access token and the short-lived stream capability must stay on the app server. Expose an authenticated route in your own app that opens the GEA stream and relays its body and protocol headers to the browser:
// app/api/gea-agent-chat/route.ts
import { createWorkspaceClient } from "@gea-ai/app-sdk";
const streamHeaders = [
"cache-control",
"content-type",
"retry-after",
"x-vercel-ai-ui-message-stream",
] as const;
async function getGeaForCurrentUser() {
// Load and refresh the current user's OAuth token in your app backend.
const connection = await loadUserGeaConnection();
return createWorkspaceClient({
getAccessToken: connection.getAccessToken,
baseUrl: "https://musegea.com",
organizationSlug: connection.organizationSlug,
workspaceSlug: connection.workspaceSlug,
});
}
export async function POST(request: Request) {
const { agentId, message } = await request.json();
const gea = await getGeaForCurrentUser();
// Do not automatically retry this write after an ambiguous network failure.
const run = await gea.agentChats.start({ agentId, message });
return Response.json({ chatId: run.chatId });
}
export async function GET(request: Request) {
const search = new URL(request.url).searchParams;
const chatId = search.get("chatId");
const view = search.get("view");
if (!chatId || (view !== "messages" && view !== "stream")) {
return new Response("chatId and view are required", { status: 400 });
}
const gea = await getGeaForCurrentUser();
if (view === "messages") {
return Response.json(
await gea.agentChats.messages.list({ chatId, limit: 50 }),
);
}
const upstream = await gea.agentChats.openStream({ chatId });
const headers = new Headers();
for (const name of streamHeaders) {
const value = upstream.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(upstream.body, {
headers,
status: upstream.status,
statusText: upstream.statusText,
});
}The browser can use the ai package to decode the relayed response. The
transport subclass exposes AI SDK's normal response-stream parser; the browser
does not need to understand the wire protocol itself:
"use client";
import { DefaultChatTransport, readUIMessageStream, type UIMessage } from "ai";
import { useState } from "react";
class GeaStreamTransport extends DefaultChatTransport<UIMessage> {
decode(stream: ReadableStream<Uint8Array>) {
return this.processResponseStream(stream);
}
}
const transport = new GeaStreamTransport();
function textFromMessage(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}
async function openStream(chatId: string) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await fetch(
`/api/gea-agent-chat?view=stream&chatId=${encodeURIComponent(chatId)}`,
);
if (response.status !== 503) return response;
const retryAfter = Number(response.headers.get("retry-after"));
await response.arrayBuffer();
await new Promise((resolve) =>
window.setTimeout(
resolve,
Number.isFinite(retryAfter) && retryAfter > 0
? Math.min(retryAfter * 1000, 5000)
: 500,
),
);
}
throw new Error("The Agent stream was not ready after three attempts.");
}
export function AgentResult() {
const [text, setText] = useState("");
const [persistedMessages, setPersistedMessages] = useState<unknown>(null);
async function run() {
const startResponse = await fetch("/api/gea-agent-chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
agentId: "agent-019...",
message: "Summarize this workspace.",
}),
});
if (!startResponse.ok) throw new Error("Could not start Agent chat");
const started = (await startResponse.json()) as { chatId: string };
const response = await openStream(started.chatId);
// 204 means the run completed before the stream attached. In that case,
// skip the loop and read the persisted result below.
if (response.status !== 204) {
if (!response.ok || !response.body) throw new Error("Stream failed");
const chunks = transport.decode(response.body);
for await (const message of readUIMessageStream<UIMessage>({
stream: chunks,
terminateOnError: true,
})) {
setText(textFromMessage(message));
}
}
const messagesResponse = await fetch(
`/api/gea-agent-chat?view=messages&chatId=${encodeURIComponent(started.chatId)}`,
);
if (!messagesResponse.ok) throw new Error("Could not load final messages");
setPersistedMessages(await messagesResponse.json());
}
return (
<section>
<button onClick={run} type="button">
Run Agent
</button>
<pre>{text}</pre>
<pre>{JSON.stringify(persistedMessages, null, 2)}</pre>
</section>
);
}The persisted read after streaming is the source of truth when the run completes too quickly to attach a stream or when the browser reconnects. For a complete Next.js implementation with more detailed error handling, follow the public Stream Agent responses tutorial.
