AI
Tutorial OpenAI Agents SDK TypeScript: Bangun Agent dengan Tool
Oktober 202612 menit baca

Ada. OpenAI memublikasikannya sebagai @openai/agents di npm, dikembangkan di repository openai/openai-agents-js berdampingan dengan SDK Python. README-nya mencantumkan Node.js 22 atau lebih baru, Deno, Bun, dan Cloudflare Workers dengan nodejs_compat sebagai runtime yang didukung.
Package ini mendeklarasikan zod ^4.0.0 sebagai peer dependency, dan quickstart-nya menyebut SDK memakai Zod v4 untuk schema tool dan structured output. Project yang masih di zod 3 sebaiknya upgrade dulu sebelum menulis tool. Memberikan schema zod sebagai parameters sebuah tool juga mengaktifkan validasi argumen strict.
Run akan melempar MaxTurnsExceededError. Batas default-nya 10 turn, dengan satu turn berarti satu pemanggilan model, dan Anda bisa mengubahnya per run lewat opsi maxTurns. Kalau ingin mengembalikan jawaban fallback, atur errorHandlers.maxTurns agar mengembalikan finalOutput.
Bisa. Panggil run() dengan stream: true, lalu kembalikan stream.toTextStream() yang di-pipe lewat TextEncoderStream sebagai body Response. Berikan signal dari request supaya request yang dibatalkan ikut menghentikan run, dan await atau catch stream.completed, yang baru selesai setelah run dan pekerjaan persistensi tuntas.
Gunakan provider openai:agents dari promptfoo dengan agent yang di-export dari file TypeScript dan tracing aktif. Assertion trajectory seperti trajectory:tool-used, trajectory:tool-args-match, dan trajectory:tool-sequence memeriksa tool mana yang berjalan dan dengan argumen apa. Perlu dicatat bahwa dokumentasi provider mengunci @openai/agents ke ^0.11.8, dan mock mode tidak mendukung objek handoff() eksplisit maupun hosted tool.

Ringkasan Utama
OpenAI Agents SDK untuk TypeScript, @openai/agents 0.18.0, membangun agent yang memakai tool dari tiga bagian: tool() dengan schema zod v4, Agent dengan tools dan handoffs, serta run() dengan opsi stream, context, dan maxTurns. Lakukan otorisasi di dalam execute, tetapkan model secara eksplisit, dan uji trajectory tool call dengan provider openai:agents dari promptfoo.
Sebagian besar contoh OpenAI Agents SDK ditulis dalam Python. Bagi tim yang back end ERP-nya memakai NestJS dan portalnya Next.js, sidecar Python untuk asisten status pesanan berarti deployment kedua, salinan kedua dari tipe sales order, dan satu network hop antara agent dan database yang justru menjadi alasan agent itu ada.
Tutorial ini membangun agent tersebut dengan OpenAI Agents SDK untuk TypeScript, yang dipublikasikan di npm sebagai @openai/agents dan berada di versi 0.18.0 saat tulisan ini dibuat. Isinya mencakup function tool bertipe zod, hosted file search tool, handoff ke agent spesialis refund, streaming lewat route handler Next.js, batas maxTurns dengan fallback, dan eval suite di promptfoo. Semua nama opsi diambil dari panduan resmi SDK yang ditautkan di akhir, dan kalau dua halaman resmi saling bertentangan, saya sebutkan.
Instalasinya cukup satu: @openai/agents ditambah zod. README-nya mencantumkan Node.js 22 atau lebih baru, Deno, Bun, dan Cloudflare Workers dengan nodejs_compat aktif sebagai runtime yang didukung, dan package ini mendeklarasikan zod ^4.0.0 sebagai peer dependency. Jadi codebase yang masih di zod 3 harus menyelesaikan upgrade itu dulu sebelum menulis tool pertama. API key dibaca secara lazy dari OPENAI_API_KEY saat SDK pertama kali membutuhkan client.
npm install @openai/agents zod
# 0.18.0 on npm at the time of writing. It declares zod ^4.0.0 as a
# peer dependency, so a project still on zod 3 upgrades first.
# .env.local
OPENAI_API_KEY=sk-...
# Pin the model. The SDK default is documented as subject to change,
# and two official pages already disagree about what it is.
AGENT_MODEL=gpt-5.6-lunaPengaturan yang sekarang saya anggap wajib adalah model. Panduan models di SDK menyebut agent tanpa model akan memakai default, saat ini gpt-5.6-luna dengan reasoning effort none dan verbosity rendah, dan OPENAI_DEFAULT_MODEL bisa menggantinya untuk seluruh proses. Halaman provider promptfoo untuk SDK yang sama justru menyebut gpt-5.4-mini sebagai default untuk v0.10 ke atas dan memperingatkan bahwa nilai itu bisa berubah. Dua sumber resmi yang tidak sepakat soal default adalah alasan paling jelas untuk tidak pernah bergantung padanya.
Function tool adalah tool() dengan name, description, schema parameters, dan fungsi execute. Dengan schema zod, SDK mengaktifkan strict mode, sehingga argumen yang gagal validasi dikembalikan ke model sebagai error dan tidak pernah sampai ke kode Anda. Argumen kedua execute adalah RunContext, yang membawa objek apa pun yang Anda berikan sebagai context ke run(). Di sini isinya tenant dan user dari session yang sudah terautentikasi: nilai yang tidak pernah dipilih model dan tidak bisa ditimpanya.
// lib/agent/tools.ts
import { tool, type RunContext } from '@openai/agents';
import { z } from 'zod';
import { db } from '@/lib/db';
// App state the model never chooses: who is asking, for which company.
// It travels in run(..., { context }) and stays in this process.
export interface ErpContext {
tenantId: string;
userId: string;
}
export const getSalesOrder = tool({
name: 'get_sales_order',
description:
'Look up one sales order by its number, for example SO-2026-00412. ' +
'Returns status, promised delivery date and invoice state.',
// A zod schema switches on strict mode: bad arguments go back to the
// model as an error and never reach execute().
parameters: z.object({
orderNumber: z.string().startsWith('SO-').describe('Sales order number'),
}),
// A slow ERP query becomes a "timed out" tool result the model can
// explain, instead of a chat request that hangs.
timeoutMs: 5_000,
async execute({ orderNumber }, runContext?: RunContext<ErpContext>) {
const ctx = runContext?.context;
if (!ctx) throw new Error('get_sales_order called without ERP context');
// Authorise HERE, against the order number the model actually chose.
// isEnabled runs before any arguments exist, so it cannot do this.
const order = await db.salesOrder.findFirst({
where: { number: orderNumber, tenantId: ctx.tenantId },
select: { number: true, status: true, promisedDate: true, invoiceStatus: true },
});
// Return, do not throw, for "not found": the run continues and the
// agent can ask the user to check the number. A throw is rethrown.
return order ?? { error: 'No order ' + orderNumber + ' for this company' };
},
});Dua detail layak dipasang di setiap tool yang menyentuh database. timeoutMs membatasi setiap pemanggilan, dan dalam mode default error_as_result, timeout mengembalikan pesan ke model bahwa tool tersebut timeout, sehingga query ERP yang lambat menjadi sesuatu yang bisa dijelaskan agent, bukan request yang menggantung. Mengembalikan objek biasa untuk kasus data tidak ditemukan, alih-alih throw, membuat run tetap berjalan: SDK melakukan serialisasi hasil non-string untuk model, sedangkan exception dari execute di-rethrow secara default karena errorFunction default dinonaktifkan.
isEnabled menentukan apakah model bisa melihat sebuah tool pada turn ini, tetapi ia berjalan sebelum model menghasilkan argumen apa pun, dan panduan tools menegaskan bahwa isEnabled tidak menggantikan otorisasi yang bergantung pada argumen tersebut. Periksa tenant di dalam execute, terhadap nomor order yang benar-benar dipilih model, seperti pada kode di atas.
SDK ini menyediakan lebih banyak cara memberi kemampuan pada agent daripada yang ditunjukkan quickstart, dan semuanya berbeda dalam hal di mana pekerjaan dijalankan dan siapa yang akhirnya menjawab user. Inilah empat yang penting untuk agent pertama.
| Primitive | Dijalankan di mana | Siapa yang menjawab user | Pakai saat |
|---|---|---|---|
| Function tool tool() | Proses Node Anda, di dalam execute | Agent pemanggil | Membaca atau menulis ke sistem Anda sendiri, seperti database ERP atau API internal |
| Hosted tool: fileSearchTool, webSearchTool, codeInterpreterTool | Server OpenAI, di samping model, lewat Responses API | Agent pemanggil | Mencari di vector store yang Anda upload, web search, atau eksekusi kode di sandbox |
| agent.asTool() | Run bersarang dari agent lain | Agent pemanggil, yang tetap memegang kendali | Langkah spesialis, misalnya ringkasan, yang output-nya dipakai ulang oleh parent |
| handoff() | Run yang sama, dengan agent aktif yang berbeda | Agent spesialis, yang mengambil alih percakapan | Pekerjaan lain dengan instruksi berbeda, seperti refund |
Hosted tool paling mudah ditambahkan sekaligus paling mudah disalahgunakan. Panduan tools mencantumkannya untuk model Responses API, yang menjadi default SDK, jadi memindahkan proses ke Chat Completions dengan setOpenAIAPI membuatnya tidak bisa dipakai. Hosted tool juga berjalan di tempat yang tidak bisa disadap kode Anda, artinya tidak ada execute untuk logging, timeout, atau otorisasi. Untuk kebijakan retur itu tidak masalah, karena dokumennya sama dan publik untuk semua pelanggan. Untuk apa pun yang terikat tenant, tulis function tool.
// lib/agent/tools.ts (continued)
import { fileSearchTool } from '@openai/agents';
// Runs on OpenAI's servers against a vector store uploaded once from the
// returns-policy PDF. There is no execute() to log, time out or authorise,
// so nothing tenant-specific belongs in this store.
export const returnsPolicySearch = fileSearchTool(
process.env.RETURNS_POLICY_VECTOR_STORE_ID!,
{ maxNumResults: 3 },
);Handoff disajikan ke model sebagai tool bernama transfer_to_ diikuti nama agent, jadi agent bernama Refunds agent menjadi transfer_to_refunds_agent. Membungkus target dengan handoff() menambahkan inputType, schema zod kecil yang diisi model saat memilih handoff, dan callback onHandoff yang menerima nilai hasil parse. Saya memakainya untuk alasan refund, yang masuk ke audit log sebelum agent spesialis sempat berbicara.
// lib/agent/agents.ts
import { Agent, handoff } from '@openai/agents';
import { z } from 'zod';
import { getSalesOrder, returnsPolicySearch, type ErpContext } from './tools';
const MODEL = process.env.AGENT_MODEL ?? 'gpt-5.6-luna';
export const refundsAgent = new Agent<ErpContext>({
name: 'Refunds agent',
model: MODEL,
instructions:
'You handle refund and return requests. Quote the returns policy you ' +
'found with file search. Never promise a refund amount.',
tools: [returnsPolicySearch, getSalesOrder],
});
// The model fills this in when it picks the handoff; onHandoff gets it parsed.
const RefundReason = z.object({
reason: z.enum(['damaged', 'late_delivery', 'wrong_item', 'other']),
});
// Agent.create, not new Agent: TypeScript then infers the union of
// finalOutput types across every agent this one can hand off to.
export const orderAgent = Agent.create({
name: 'Order status agent',
model: MODEL,
instructions:
'Answer questions about sales orders using get_sales_order. ' +
'If the customer wants money back or a return, hand off.',
tools: [getSalesOrder],
handoffs: [
// The model sees this as a tool called transfer_to_refunds_agent.
handoff(refundsAgent, {
inputType: RefundReason,
toolDescriptionOverride:
'Transfer to the refunds agent when the customer asks for a refund or a return.',
onHandoff: async (_ctx, input) => {
// Lands in the audit log before the specialist says a word.
console.info('refund handoff', input?.reason);
},
}),
],
});Dua detail dari panduan handoffs membentuk kode ini. Buat agent triage dengan Agent.create, bukan new Agent, supaya TypeScript menyimpulkan union dari tipe final output di seluruh graph handoff. Lalu, secara default agent penerima melihat seluruh percakapan; berikan inputFilter seperti removeAllTools dari @openai/agents-core/extensions kalau agent spesialis tidak perlu mewarisi tool call dari agent sebelumnya. Setelah run, result.lastAgent memberi tahu agent mana yang menghasilkan jawaban.
Memberikan stream: true membuat run() mengembalikan StreamedRunResult, bukan hasil yang sudah selesai. Untuk kotak chat, toTextStream() adalah tingkat detail yang tepat: ia hanya memancarkan teks dari assistant, sehingga tool call, handoff, dan permintaan approval tetap di server kecuali Anda sengaja meneruskannya dari event stream lengkap.
// app/api/agent/route.ts
import { run } from '@openai/agents';
import { orderAgent } from '@/lib/agent/agents';
import { getSession } from '@/lib/auth';
// The SDK README lists Node.js 22+, Deno, Bun and Cloudflare Workers.
// The Edge runtime is not on that list, so say nodejs out loud.
export const runtime = 'nodejs';
export async function POST(req: Request) {
const session = await getSession(req);
if (!session) return new Response('Unauthorized', { status: 401 });
const { message } = (await req.json()) as { message: string };
const stream = await run(orderAgent, message, {
stream: true,
// Tenant comes from the session, never from the request body.
context: { tenantId: session.tenantId, userId: session.userId },
maxTurns: 6,
// Aborting this signal is how the streaming guide stops a run early.
signal: req.signal,
});
// Settles after the run AND any persistence work; log failures here.
stream.completed.catch((err) => console.error('agent run failed', err));
// toTextStream() emits assistant text only. Tool calls, handoffs and
// approval requests stay on the server.
return new Response(stream.toTextStream().pipeThrough(new TextEncoderStream()), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}Loop runner-nya sederhana: panggil model, jalankan tool call yang ada, tambahkan hasilnya, panggil model lagi, dan berhenti saat model mengembalikan teks tanpa tool call. Setiap putaran adalah satu turn, maxTurns default-nya 10, dan saat batas itu tercapai, SDK melempar MaxTurnsExceededError. Agent yang terus mengulang query sebuah order karena bingung dengan hasilnya akan menabrak batas itu, dan batas itulah yang menentukan apakah kesalahan tersebut hanya memakan enam pemanggilan model atau tagihan tanpa ujung.
// A nightly job answering queued customer emails: failures should be loud.
import {
run,
MaxTurnsExceededError,
ModelBehaviorError,
ToolCallError,
} from '@openai/agents';
import { orderAgent } from '@/lib/agent/agents';
import type { ErpContext } from '@/lib/agent/tools';
import { alertOps } from '@/lib/ops';
export async function answerEmail(question: string, ctx: ErpContext) {
try {
// Default maxTurns is 10. One turn = one model call, so every
// "call tool, read result, call again" lap spends one.
const result = await run(orderAgent, question, { context: ctx, maxTurns: 6 });
return { answer: result.finalOutput, answeredBy: result.lastAgent?.name };
} catch (err) {
if (err instanceof MaxTurnsExceededError) {
// Six model calls without a final answer: the agent is looping.
return { answer: null, reason: 'max_turns' };
}
if (err instanceof ModelBehaviorError) {
// Malformed output, or a call to a tool that does not exist.
return { answer: null, reason: 'model_behaviour' };
}
if (err instanceof ToolCallError) {
// execute() threw: the database needs attention, not the prompt.
await alertOps('agent tool failure', err);
}
throw err;
}
}
// In user-facing chat, a polite fallback beats a 500:
const result = await run(orderAgent, message, {
context,
maxTurns: 6,
errorHandlers: {
maxTurns: () => ({
finalOutput: 'I could not finish that lookup. A colleague will follow up on this order.',
includeInHistory: false,
}),
},
});errorHandlers mengubah kegagalan yang didukung menjadi jawaban akhir, bukan exception. Key-nya adalah maxTurns, modelRefusal, dan invalidFinalOutput, dengan default sebagai fallback, dan handler mengembalikan finalOutput yang harus cocok dengan outputType agent, ditambah flag includeInHistory yang opsional. Saya lebih suka catch eksplisit di background job, karena kegagalan di sana harus sampai ke orang, dan handler di chat yang dilihat user, karena fallback yang sopan lebih baik daripada HTTP 500.
Memberikan maxTurns: null menonaktifkan batas sepenuhnya. Panduan running agents mendokumentasikan opsi ini, tetapi pada agent yang punya tool untuk menulis data, opsi ini menghapus satu-satunya batas berapa kali model boleh memanggil tool tersebut dalam satu run.
Hanya memeriksa pesan akhir akan melewatkan kegagalan yang paling penting pada agent: jawaban benar yang dicapai lewat tool yang salah, atau tool yang benar dipanggil dengan nomor order yang salah. Provider openai:agents dari promptfoo menjalankan agent yang di-export dari file TypeScript, meneruskan vars setiap test ke run context, dan dengan tracing aktif memungkinkan Anda membuat assertion atas jalurnya sendiri lewat trajectory:tool-used, trajectory:tool-args-match, trajectory:tool-sequence, dan trajectory:goal-success.
# promptfooconfig.yaml
# Run the project-local binary (npx promptfoo eval) so the eval and the
# agent load the same @openai/agents installation.
prompts:
- '{{query}}'
providers:
- id: openai:agents:order-agent
config:
agent: file://./lib/agent/order-agent.eval.ts # default export = orderAgent
model: gpt-5.6-luna # pin the baseline; do not inherit the SDK default
maxTurns: 6
tracing: true # trajectory:* assertions read the trace
tests:
- vars:
tenantId: tenant-acme # test vars arrive in runContext.context
userId: eval-user
query: 'Has SO-2026-00412 shipped yet?'
assert:
- type: trajectory:tool-used
value: get_sales_order
- type: trajectory:tool-args-match
value:
name: get_sales_order
args:
orderNumber: 'SO-2026-00412'
- type: trajectory:goal-success
value: 'Tell the user whether SO-2026-00412 has shipped'Ada dua hal di halaman promptfoo yang perlu dibaca sebelum run pertama. Baris instalasinya mengunci @openai/agents ke ^0.11.8, dan caret pada versi 0.x berhenti sebelum 0.12.0, padahal rilis terbaru di npm adalah 0.18.0; karena halaman yang sama juga meminta Anda memakai promptfoo lokal di project agar eval dan agent berbagi satu instalasi SDK, periksa versi mana yang benar-benar di-resolve oleh lockfile Anda. Lalu, mock mode, yang mengganti hasil tool untuk test yang deterministik, fails closed untuk objek handoff() eksplisit dan untuk hosted tool, dan agent ini memakai keduanya. Karena itu saya membagi suite menjadi tiga.
@openai/agents membundel @openai/agents-core, @openai/agents-openai, dan @openai/agents-realtime di versi yang sama, saat ini 0.18.0, dan bergantung pada openai ^7.2.0. Kalau aplikasi sudah memanggil package openai secara langsung di tempat lain, pastikan keduanya resolve ke satu salinan saja.
SDK TypeScript ini sudah cukup lengkap sehingga tim NestJS atau Next.js tidak punya alasan menjalankan sidecar Python untuk agent dengan tool. Aturan yang saya bawa dari pembangunan ini singkat: lakukan otorisasi di execute, bukan di isEnabled; jauhkan data tenant dari hosted tool; tetapkan model; batasi maxTurns dan putuskan apa yang terjadi saat batas itu tercapai; dan uji trajectory-nya, bukan hanya balasannya.
Sumber