Build the Control Plane Independently of the Model Provider
Business state, budgets, permissions, and acceptance must not vanish on a change of SDK. A provider adapter is responsible for the model loop, while the application is responsible for the system. It helps to separate three layers: the domain defines work items, artifacts, and acceptance; the control plane manages the graph, budget, state, identity, and trace; the model adapter turns a provider-specific response, tool call, or handoff into your typed events.
- Domain: task and outcome
- Control plane: graph, policy, state
- Adapter: SDK run and tools
- Provider: model service
- Evidence: external gate
Provider independence rests on a narrow handler interface - behind it the model is interchangeable.
export interface AgentResult {
readonly summary: string;
readonly artifacts: readonly ArtifactDraft[];
readonly evidence: readonly Evidence[];
readonly usage: Usage;
}
export interface AgentContext {
readonly runId: string;
readonly task: WorkItem;
readonly inputArtifacts: readonly Artifact[];
readonly attempt: number;
}
export interface AgentHandler {
readonly contract: AgentContract;
run(context: AgentContext): Promise<AgentResult>;
}
export interface BudgetLimits {
readonly maxCalls: number;
readonly maxTokens: number;
readonly maxCostUnits: number;
readonly maxSimulatedLatencyMs: number;
}
export interface BudgetSnapshot extends BudgetLimits {
readonly usedCalls: number;And the control plane itself validates the graph and contracts before the run and owns the execution.
export class Orchestrator {
readonly #tasks: readonly WorkItem[];
readonly #handlers: ReadonlyMap<string, AgentHandler>;
readonly #limits: BudgetLimits;
readonly #maxConcurrency: number;
public constructor(input: {
readonly tasks: readonly WorkItem[];
readonly handlers: readonly AgentHandler[];
readonly limits: BudgetLimits;
readonly maxConcurrency: number;
}) {
validateWorkGraph(input.tasks);
validateHandlers(input.handlers);
if (!Number.isInteger(input.maxConcurrency) || input.maxConcurrency < 1) {
throw new Error("maxConcurrency must be a positive integer");
}
this.#tasks = input.tasks;
this.#handlers = new Map(input.handlers.map((handler) => [handler.contract.id, handler]));
this.#limits = input.limits;
this.#maxConcurrency = input.maxConcurrency;
}
public async run(runId: string): Promise<RunReport> {
const status: Record<string, TaskStatus> = createInitialStatus(this.#tasks);
const budget = new BudgetLedger(this.#limits);
const eventLog = new EventLog();
const trace = new TraceRecorder();
const evidence: Evidence[] = [];The OpenAI Agents SDK offers agents, agents-as-tools, handoffs, guardrails, sessions, human-in-the-loop, and tracing; the Claude Agent SDK gives a programmable agent loop in Python and TypeScript. These capabilities are used through adapters, without giving the SDK ownership of your domain database and irreversible effects. The adapter boundary is narrow: normalize model output into a typed result, turn a tool request into a checkable host call, pass usage and timing to the shared budget/trace, serialize pause/resume, but don't decide domain acceptance inside free text.
The practical payoff. Stable outcome, budget, and trace schemas let you A/B-test models and frameworks on one task bank without rewriting the system for each SDK.
The control plane is decoupled from the provider. Next are three chapters on specific products, and the first distinguishes Claude Code subagents and agent teams.