Oct 29, 202611 min read

How to Deploy an OpenAI Agents SDK Agent to Your Own AWS or GCP Account

Run an OpenAI Agents SDK agent in a TypeScript service, back its sessions with Postgres, and deploy it to your own cloud with Encore.

The OpenAI Agents SDK for TypeScript, published as @openai/agents, wraps the tool loop, handoffs and guardrails around OpenAI models. Its quick start keeps conversation memory in process, and that stops working once the agent runs as a service with more than one instance or restarts on every deploy.

This guide builds a small notes agent with @openai/agents 0.21 inside an Encore service. A custom Session stores the conversation in Postgres, the agent gets two tools backed by the same database, and the OpenAI key lives in a secret.

What you will build

One service called agent with two endpoints:

  • POST /threads creates a thread.
  • POST /threads/:id/messages runs the agent with that thread's session and returns the final output.

The tools, save_note and search_notes, use a notes table. The layout follows the Cursor Agents example, adapted to the OpenAI Agents SDK.

Why self-host the agent backend

OpenAI offers hosted options for storing conversations and running agents, and hosted agent platforms in general take care of compute, storage and a dashboard. Running the Agents SDK in your own service makes sense when the tools need data that already lives in your database, when session history should stay in an account you control, or when the agent is one feature inside a backend with its own users and permissions.

In exchange you run storage, secrets, an HTTP layer and a deploy target, while the model calls still go to OpenAI. What moves into your account is the session history, the tool code and the data the tools touch.

Keeping session items in a table you own also makes them easy to inspect. A SELECT on the session_items table built below for one thread shows every prompt, tool call and tool result the model saw, in order, which is often faster than reading a trace when a reply looks wrong. It also gives you one place to delete a user's history when they ask, since no other copy is kept by this app.

Set up the project

You need Node 22 or later (the @openai/agents README lists Node.js 22 or later), Docker for the local database, and the Encore CLI. Install the CLI with the command for your operating system below, then create the app, selecting Empty app as the template when prompted. The new app already has encore.app, package.json with encore.dev and a tsconfig.json, so you only add the agent packages:

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create openai-notes$ cd openai-notes$ npm install @openai/agents zod

Then define the service:

// agent/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("agent");

The agent also needs a database for its state and a secret for the API key, which is where Encore comes in.

How Encore provisions the database and secret

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions and deploys into your own AWS or GCP account. The session table and the key are declared in the service code, encore run starts Postgres locally with the migration applied, and a deploy creates the same database and secret in your cloud.

For an AI coding agent editing this code, the secret and the database are names in TypeScript rather than settings in a console, which keeps its changes inside files a reviewer can read.

Add the database

The SDK represents a conversation as a list of items: user messages, assistant messages, function calls and their outputs. The session_items table stores one item per row in order.

-- agent/migrations/1_create_tables.up.sql CREATE TABLE threads ( id TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Items of each thread's Agents SDK session, in order. CREATE TABLE session_items ( id BIGSERIAL PRIMARY KEY, thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE, item JSONB NOT NULL ); CREATE INDEX session_items_thread ON session_items (thread_id, id); CREATE TABLE notes ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The database is declared in one line, and Encore applies the migrations in ./migrations when encore run starts and on each deploy. The database docs cover the details.

// agent/db.ts import { SQLDatabase } from "encore.dev/storage/sqldb"; export const db = new SQLDatabase("agent", { migrations: "./migrations" });

Implement a Postgres session

The sessions guide describes the Session interface. run() calls getItems before the model call and addItems with the new items after it. getItems takes an optional limit that should return the most recent items, oldest first, which the subquery handles. In Postgres, LIMIT NULL means no limit.

// agent/session.ts import type { AgentInputItem, Session } from "@openai/agents"; import { db } from "./db"; const parse = (v: AgentInputItem | string): AgentInputItem => typeof v === "string" ? JSON.parse(v) : v; // An Agents SDK session stored in Postgres: the SDK loads the thread's items // before each run and appends the new ones after it. export class PostgresSession implements Session { constructor(private readonly threadId: string) {} async getSessionId() { return this.threadId; } async getItems(limit?: number): Promise<AgentInputItem[]> { const rows: AgentInputItem[] = []; for await (const row of db.query<{ item: AgentInputItem | string }>` SELECT item FROM ( SELECT id, item FROM session_items WHERE thread_id = ${this.threadId} ORDER BY id DESC LIMIT ${limit ?? null} ) recent ORDER BY id`) { rows.push(parse(row.item)); } return rows; } async addItems(items: AgentInputItem[]) { for (const item of items) { await db.exec` INSERT INTO session_items (thread_id, item) VALUES (${this.threadId}, ${JSON.stringify(item)}::text::jsonb)`; } } async popItem(): Promise<AgentInputItem | undefined> { const row = await db.queryRow<{ item: AgentInputItem | string }>` DELETE FROM session_items WHERE id = ( SELECT max(id) FROM session_items WHERE thread_id = ${this.threadId} ) RETURNING item`; return row ? parse(row.item) : undefined; } async clearSession() { await db.exec`DELETE FROM session_items WHERE thread_id = ${this.threadId}`; } }

Define the tools

tool() takes a name, a description, Zod parameters and an execute function. The SDK turns the Zod schema into the JSON schema the model sees.

// agent/tools.ts import { tool } from "@openai/agents"; import { z } from "zod"; import { db } from "./db"; export const saveNote = tool({ name: "save_note", description: "Save a note with a short title and a body.", parameters: z.object({ title: z.string(), body: z.string() }), execute: async ({ title, body }) => { const row = await db.queryRow<{ id: number }>` INSERT INTO notes (title, body) VALUES (${title}, ${body}) RETURNING id`; return { saved: row!.id }; }, }); export const searchNotes = tool({ name: "search_notes", description: "Find notes whose title or body contains the given text.", parameters: z.object({ query: z.string() }), execute: async ({ query }) => { const pattern = `%${query}%`; const notes: { id: number; title: string; body: string }[] = []; for await (const n of db.query<{ id: number; title: string; body: string }>` SELECT id, title, body FROM notes WHERE title ILIKE ${pattern} OR body ILIKE ${pattern} ORDER BY id DESC LIMIT 20`) { notes.push(n); } return { notes }; }, });

Write the endpoints

The agent is defined once at module level. The key comes from an Encore secret and is passed to a Runner created for each request, and maxTurns caps how many model calls one message can trigger.

// agent/api.ts import { randomUUID } from "node:crypto"; import { Agent, OpenAIProvider, Runner } from "@openai/agents"; import { api, APIError, type Header } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { db } from "./db"; import { PostgresSession } from "./session"; import { saveNote, searchNotes } from "./tools"; const openaiApiKey = secret("OpenAIApiKey"); const notesAgent = new Agent({ name: "Notes assistant", instructions: `You keep the user's notes with the save_note and search_notes tools. Search before answering a question about notes, and never make up a note.`, model: "gpt-5.4-mini", tools: [saveNote, searchNotes], }); export const createThread = api( { expose: true, method: "POST", path: "/threads" }, async (): Promise<{ id: string }> => { const id = randomUUID(); await db.exec`INSERT INTO threads (id) VALUES (${id})`; return { id }; }, ); interface SendRequest { id: string; prompt: string; // Optional per-user key. Without it, the app's own key is used. userKey?: Header<"X-OpenAI-Key">; } export const send = api( { expose: true, method: "POST", path: "/threads/:id/messages" }, async ({ id, prompt, userKey }: SendRequest): Promise<{ reply: string }> => { const thread = await db.queryRow`SELECT id FROM threads WHERE id = ${id}`; if (!thread) throw APIError.notFound("thread not found"); // A runner per request, so concurrent requests never share a key. const runner = new Runner({ modelProvider: new OpenAIProvider({ apiKey: userKey || openaiApiKey() }), tracingDisabled: true, }); const result = await runner.run(notesAgent, prompt, { session: new PostgresSession(id), maxTurns: 8, }); return { reply: String(result.finalOutput ?? "") }; }, );

The session does the loading and saving, so the endpoint only sends the new prompt. result.finalOutput is the agent's last text output. The SDK exports traces to OpenAI by default. The runner here sets tracingDisabled: true, and the tracing guide covers the other options if you want those traces.

How state and resume work

A Session is the Agents SDK's hook for conversation memory. Before a run, the runner calls getItems to load earlier items and puts them in front of the new prompt. After the run, it calls addItems with everything the run produced, including tool calls and tool results. popItem and clearSession exist for corrections and resets.

The PostgresSession class implements those methods against the session_items table, keyed by thread id, and returns items in insertion order. With the session as the only state, any instance of the service can serve the next message of any thread, and history survives a deploy or a restart of encore run.

The endpoint never sees the item format. It passes a new session object for the thread and the prompt, and reads result.finalOutput at the end.

API key per user or per app

You can pay for the model calls in two ways. With one key per app, the service owns an OpenAI key in a secret, every user's requests are billed to it, and you need your own limits per user. With one key per user, each request brings the user's key, usage lands on their account, and the service stores no key at all.

setDefaultOpenAIKey sets a process-wide default, which is fine for one app key and wrong for per-user keys, since two concurrent requests would overwrite each other. The endpoint creates a Runner per request with its own OpenAIProvider instead, and disables the runner's tracing so the user's key is not also used to export traces. The send endpoint here supports both: an optional X-OpenAI-Key header, declared with Encore's Header type, wins over the secret when it is present. Drop the secret if every user brings a key, or drop the header if none of them should. Either way, do not log the header, and do not store a user's key in the threads table.

Run it locally

Set the API key for your local and development environments with the Encore CLI, which prompts for the value:

~/openai-notes
$ encore secret set --type dev,local OpenAIApiKey

Encore stores this value and syncs it to everyone working on the app, so use a personal development key, or override it for your machine only in a .secrets.local.cue file in the app root, next to encore.app.

encore run starts Postgres locally, applies the migration and serves the API on port 4000:

~/openai-notes
$ encore run$ curl -X POST localhost:4000/threads$ curl -X POST localhost:4000/threads/<id>/messages \ -d '{"prompt":"Remember that the deploy freeze starts Friday."}'$ curl -X POST localhost:4000/threads/<id>/messages \ -d '{"prompt":"When does the deploy freeze start?"}'

Observability in local development

encore run prints a URL on localhost:9400 that serves the Encore Development Dashboard, and it logs a trace id with every request it serves. According to Encore's tracing docs, traces are captured automatically in local development as well as in deployed environments, and include API calls, database queries and structured logs. For an agent, the database queries are the useful part: they show whether a turn called search_notes or answered from nothing.

A trace in the Encore local development dashboard showing the request timeline, the request and response bodies, and the log lines for one API call.
A request trace in the local development dashboard.

Deploy to your own AWS or GCP account

~/openai-notes
$ encore secret set --type prod OpenAIApiKey$ git add -A && git commit -m "OpenAI Agents notes agent"$ git push encore main

In the Encore dashboard, connect your AWS or GCP account and create an environment on it. Each environment gets its own database and secret values.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

When the environment is created, Encore provisions what the code declares. For this app, that is one Postgres database for the agent service and one secret: RDS and Secrets Manager on AWS, Cloud SQL and Secret Manager on GCP. Encore provisions an IAM role for the service. The service runs as a container built on Encore's default base image for TypeScript apps, node:24-trixie.

Add an auth handler before exposing the endpoints publicly, and scope threads to the authenticated user so one user cannot read another's session.

Frequently asked questions

How do I deploy an OpenAI Agents SDK agent to AWS or GCP?

You build a TypeScript API that runs an OpenAI Agents SDK agent and stores its conversation memory in Postgres, so it survives restarts and deploys. It runs in your own AWS or GCP account, and Encore provisions the database and the API key secret there when you push.

What does a Session do in the OpenAI Agents SDK?

A session stores the items of a conversation. When run() receives a session, it loads the stored items before the run and adds the new items after it, so you send only the new prompt each turn.

Which methods does a custom Session need?

getSessionId, getItems, addItems, popItem and clearSession. getItems takes an optional limit that should return the most recent items in chronological order.

Which Node.js version does @openai/agents need?

The package README lists Node.js 22 or later, along with Deno and Bun, as supported environments. The package.json has no engines field, so npm will not enforce it.

How do I use a different OpenAI key for each request in the Agents SDK?

Create a Runner per request with modelProvider: new OpenAIProvider({ apiKey }) and call runner.run instead of the top-level run function. setDefaultOpenAIKey sets a process-wide key, so it is only safe when every request uses the same key.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders