Claude Agent SDK vs OpenAI Agents SDK vs Google ADK: One Agent, Three Builds
Claude Agent SDK vs OpenAI Agents SDK vs Google ADK: one nutrition-label agent built three times, with what each kit handles and what you still write yourself.
This article is published in English.
Every large AI vendor now ships an agent SDK, and the pitch is nearly identical. Give us your tools and a prompt, and we'll run the loop: tool calls, structured output, sessions and tracing included. If the marketing is that similar, what really sets these kits apart, and which should you build on?
The way to find out was to build the same agent three times: with Anthropic's Claude Agent SDK, with the OpenAI Agents SDK and with Google's Agent Development Kit (ADK). The agent isn't a toy. It's a nutrition-label investigator that reads a photo, reasons through a handful of tools, hands back a verdict and improves as it sees more labels. With one agent and three harnesses, any difference in the result belongs to the SDK and not to the problem.
The short version: the three converge almost completely on the hard part, the reasoning loop, and split apart on everything wrapped around it. They differ in how a tool gets declared, how an image goes in and whether you get typed output at all. Most revealing of all, they differ in what none of them does for you. By the end you'll know which kit suits which job and how much work stays on your plate after you pick one.
One more thread runs through all three builds. The agent was made to learn from experience. It remembers products it has already examined and the additive names it has figured out how to resolve, and it persists both to disk, so each later run leans a little less on the model, finishes faster and behaves more deterministically. The model's weights never change. The agent is what accumulates skill. None of the SDKs gives you that out of the box, which makes it a handy lens for seeing where the harness stops and your own engineering starts.
First, what counts as an agent
A plain language model isn't an agent. Think of it as a function that takes text and images and returns text. Nothing carries over between calls, it can't execute code or fetch a page, and it can't even call itself again. Hand it a nutrition label and it will tell you how it would go about investigating, yet the investigation never happens.
An agent is a model running in a loop with tools. The pattern never changes. You give the model a goal and a list of tools it may use. It answers either with a final result or with a request to call a tool, such as "look up E211." If it asked for a tool, something executes it and returns the output, and the model goes again. The cycle repeats until a final answer appears.
That cycle is the entire idea: the model decides, a tool runs, the result comes back, the model decides again. Our investigator goes around many times, once for the product lookup, once per additive, then for the Healthier Choice check, the grade and a final synthesis. At every turn the model chooses its next step from what it just learned. That's what makes the thing agentic, and it's why one API call wrapped in a for loop doesn't qualify.
Notice what the model actually emits: only "I'd like to call check_sfa_additive with these arguments." Something else has to convert that wish into a real function call and keep everything moving. That something is the harness, and it contains more unglamorous plumbing than most people expect. It sorts into three layers. A Development layer is where you build the agent. A Runtime layer is where you operate one in production. A Control Plane is where you govern a whole fleet. The Development layer is what gets the loop turning at all, and it's where these SDKs sit.
Start with the Development layer, where a single agent gets built. It consists of the loop itself (call the model, spot tool-call requests, repeat until done, bail out safely on a turn limit or budget) and the persona, meaning the system prompt that tells the model who it is and how to act. Around those sit tool schemas, which describe each tool to the model in the format it expects, and tool execution, which actually runs your function, an MCP server or a built-in such as file reading and captures the output. An MCP client lets the agent reach third-party servers. Context management keeps the running transcript, deals with images and trims when it grows too long. Provider wiring covers authentication, endpoints and request formats for a given vendor. On top of that come structured output, which coerces and checks the final answer against a fixed shape, and multi-agent workflows that chain agents in sequence, in parallel or in cycles.
The SDKs make all of that easy. Move from running a script to operating a service and the Runtime layer appears, and with it a longer list. The agent needs hosted execution, meaning an endpoint with session isolation that other systems can call. It needs a tool gateway, the retries, timeouts and credential handling that keep a tool call trustworthy when an upstream API acts up, and managed memory, so session and long-term state live in a store that survives the process and can be shared between instances. It needs a workload identity to present when calling tools, so those calls aren't made with someone's personal key, and observability, with traces and spans that show which tools fired, with what, and why. Add a sandbox for the agent's own code or browser tools, durable loops that pick up from the last finished step after a crash, and scaling through concurrency and autoscaling behind the endpoint.
Beneath that sits the Control Plane, which governs many agents at once. A registry lets agents be published and discovered at runtime instead of wired together in code. Each agent needs an identity as an owned, attributable entity in the directory, and permission scoping that is enforced and not just assumed. After the fact you want an audit and compliance log showing what the fleet did, data governance over what shared memory may store and reveal, and cost and quota controls with budgets and rate limits. Agents may talk to each other over an open protocol such as A2A. Because tool output flows straight back into the model, guardrails and injection defense matter, and evaluation tells you whether answers are right and not just whether the run finished. A registry for tools and MCP servers handles cataloguing, versioning, discovery, pooling and per-server auth, and prompt versioning lets you pin, diff and roll back prompts so runs stay reproducible. In short, each of these kits is a ready-made harness. Claude's, OpenAI's and Google's all supply the loop, the tool plumbing, context handling and provider wiring, leaving you to write the interesting parts: your tools, your prompt and your output shape. ADK is the odd name out, though it does the same job, so "the SDKs" below covers all three loosely.
They part ways on which pieces of the harness they provide, how opinionated each piece is and where the harness simply ends and your code has to take over. The cleanest way to make that concrete is to build one agent in each. Once all three exist, the full harness can be laid out and marked for what each kit delivers and what it leaves to you.
The agent under test: a nutrition label investigator
A fair test has to put these kits through the work they exist for: a reasoning loop across several tools, where each next step depends on what the last one turned up. One model call dressed in framework clothing wouldn't count. The investigator does the real thing. Show it a photo of a nutrition label and it goes through five jobs.
- It reads the label with the model's own vision, extracting the product name, the complete ingredient and additive list, sugar and saturated fat per 100 ml, and whether the product is a drink. The photo is the only source of truth, with no product database to fall back on.
- For each additive it found, whether the label says
E471or "Soy Lecithin", it consults Singapore's SFA permitted-additives list and reports the status and any consumer warning. - It queries the HPB dataset on data.gov.sg to see whether the product carries the Healthier Choice Symbol.
- For beverages, it derives the expected A/B/C/D Nutri-Grade from sugar and saturated fat.
- It assembles everything into a tiered verdict, GREEN, AMBER or RED per finding, each citing the tool or label that produced it, with the model's reasoning up front.
It also doesn't start from zero each time. It remembers what it has seen and learns as it goes, so both a product examined yesterday and an additive name it once struggled to resolve are on hand next time. More on that later.
So the test bed offers a tool loop of variable length, a real Singapore data source, image input, a verdict you can sanity-check in a supermarket aisle and an agent that improves a little on every run.
Keeping the comparison fair
Anything that isn't the SDK had to stay identical. All three demos call one LiteLLM proxy, an OpenAI-compatible gateway that also serves an Anthropic /v1/messages endpoint for the Claude SDK. A single root .env holds the key, base URL and model name for each, with the models being Claude Sonnet 4.6, GPT-5.4 and Gemini 3.5 Flash. The way each SDK is pointed at that proxy ends up revealing a lot.
Every demo lives in its own uv project, so you can run one without installing the dependencies of the others. The SFA additive table was built a single time from the permitted-additives PDF that Singapore's food agency publishes, producing sfa_additives.json (roughly 540 entries, with about 270 carrying E-numbers), and then copied unchanged into each project. Test images were identical too: two real Singapore products, a Milo 3in1 sachet and a Marigold HL milk carton.
That leaves only what's genuinely the SDK's decision: how a tool is defined, how an image goes in, how the model is wired up, whether typed output exists and how you see what happened.
A heads-up before the code. The snippets below contain a recall_product tool and an e_number_hint argument. They power the memory and self-improvement covered further down, so ignore them for now.
Anthropic's Claude Agent SDK
The Claude Agent SDK sits directly on Claude Code, the agent many people already run in a terminal, and that single decision colors everything. You're not assembling a loop from primitives. You're driving a complete, battle-tested agent harness and giving it your tools and prompt. Built-in tools (Read, Write, Edit, Bash, WebFetch) are already there, the permission model and context management are solved, and your custom tools attach through an MCP server that runs inside your own process. Everything runs asynchronously through anyio.
What you're buying is a true computer-using agent, ready to go. Anything that has to read files, run shell commands, edit source or browse arrives in production-grade form on day one, since it's the same machinery that powers Claude Code. MCP is a first-class citizen, so a custom tool you write works unchanged in Claude Code, in Claude Desktop and in your own application. Among the three, this sits on the most mature agent runtime.
The cost appears in the tools, where you write the name, description and schema by hand:
@tool(
"check_sfa_additive",
"Check whether a food additive is permitted by the Singapore Food Agency, "
"using the full SFA permitted-additives list parsed from the official PDF. "
"Accepts an E-number ('E211') OR a name ('Sodium Benzoate', 'Soy Lecithin'). "
"If you pass a name and also know its E/INS number, set e_number_hint — an "
"unrecognised name that the hint resolves is REMEMBERED for next time.",
{"additive": str, "e_number_hint": str},
)
async def check_sfa_additive(args: dict) -> dict:
raw = args["additive"].strip()
hint = args.get("e_number_hint", "").strip()
entry = _resolve_additive(raw)
if not entry and hint:
entry = _resolve_additive(hint)
if entry: # learn: this label name -> this number, persisted to disk
num = entry["e_number"] or entry.get("ins") or ""
_MEMORY["learned_aliases"][_norm(raw)] = re.sub(r"\(.*\)", "", num).lower()
_save_memory(_MEMORY)
return _ok(_format_entry(entry, raw))
That {arg: type} dict takes more typing than the other two kits demand, yet it leaves no room for ambiguity. The tools are then exposed through an in-process MCP server, and the model refers to them with an mcp__<server>__<tool> prefix, which you must remember when filling in allowed_tools:
server = create_sdk_mcp_server(
name="sg-nutrition-tools",
version="1.0.0",
tools=[recall_product, check_sfa_additive, check_hcs, calculate_nutri_grade],
)
options = ClaudeAgentOptions(
system_prompt=SYSTEM_PROMPT,
mcp_servers={"sg": server},
allowed_tools=[
"mcp__sg__recall_product",
"mcp__sg__check_sfa_additive",
"mcp__sg__check_hcs",
"mcp__sg__calculate_nutri_grade",
],
model=CLAUDE_MODEL,
permission_mode="bypassPermissions",
max_turns=20,
# Point the bundled CLI at the LiteLLM proxy's Anthropic endpoint.
env={
**os.environ,
"ANTHROPIC_BASE_URL": BASE_URL,
"ANTHROPIC_AUTH_TOKEN": API_KEY,
},
)
Images go in the same do-it-yourself way. No image= argument exists. query() accepts an async iterable of message dicts, so you build the raw Claude-native content blocks on your own, a text block alongside an image block:
async def message_stream():
yield {
"type": "user",
"message": {"role": "user", "content": [
{"type": "text", "text": "Investigate this nutrition label."},
{"type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}},
]},
}
async with ClaudeSDKClient(options=options) as client:
await client.query(message_stream())
async for message in client.receive_response():
... # stream ToolUseBlock / TextBlock as they arrive
The sharp edge is model wiring. The SDK drives the bundled Claude Code CLI, which speaks Anthropic's message format, so you redirect it to the proxy through environment variables (ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN). There's no client object to pass. The upside is a completely visible loop: print each ToolUseBlock as it arrives and you can watch the per-additive cycle unfold live. Here's a run on the Milo sachet:
sausheong claude-demo % uv run python main.py ../milo.jpeg
Investigating image: ../milo.jpeg
────────────────────────────────────────────────────────────
I'll start by reading the label carefully, then run all checks in parallel.
**Reading from the label:**
- **Product:** Nestlé MILO 3in1
- **Net weight:** 27g powder sachet (add hot water to prepare — this is a **powder, not a ready-to-drink beverage**)
- **Ingredients:** Sugar, PROTOMAL® Malt Extract (Contains Barley), Skimmed Milk (Cow's Milk), Cocoa, Palm Oil, Minerals (Dicalcium Phosphate, Disodium Phosphate, Ferric Pyrophosphate), Resistant Dextrin, Flavourings, Vitamins (C, B3, D, B6, B2, B12)
- **Identifiable additives:** Dicalcium Phosphate (E341), Disodium Phosphate (E339), Ferric Pyrophosphate; Flavourings (generic — cannot identify specific ones)
Kicking off recall + HCS check + all additive checks simultaneously:
→ tool: recall_product({'product_name': 'Nestlé MILO 3in1'})
→ tool: check_hcs({'product_name': 'MILO 3in1'})
→ tool: check_sfa_additive({'additive': 'Dicalcium Phosphate', 'e_number_hint': 'E341'})
→ tool: check_sfa_additive({'additive': 'Disodium Phosphate', 'e_number_hint': 'E339'})
→ tool: check_sfa_additive({'additive': 'Ferric Pyrophosphate', 'e_number_hint': 'E450'})
All checks returned. Note: this is a **powder**, not a ready-to-drink beverage, so Nutri-Grade (which requires per-100ml liquid values) does **not apply** — I will skip `calculate_nutri_grade`.
---
## Reasoning
**Step 1 — Product recall.** This product has been seen before. The prior verdict is consistent with what I read now — I'll treat this fresh investigation as the authoritative one and use the prior as a cross-check.
**Step 2 — Label reading.**
- MILO 3in1 is a **27g powder sachet** — you add hot water to make a drink. It is classified as a "Tonic Food Drink" on the label. Because it is sold as a dry powder (not a pre-packaged ready-to-drink liquid), Nutri-Grade per-100ml calculation does not apply.
- Nutritional values per serving (27g): Energy 110 kcal, Total Fat ~2.4g, Saturated Fat ~1.2g, Total Sugars ~13.8g (of which Added Sugars ~10g), Sodium ~38mg.
- **Sugar is the #1 ingredient** — it appears first in the ingredient list, before the malt extract.
**Step 3 — Additives.**
- **Dicalcium Phosphate (E341):** SFA-permitted ✅ — used as a mineral/calcium source.
- **Disodium Phosphate (E339):** SFA-permitted ✅ — used as an acidity regulator/mineral.
- **Ferric Pyrophosphate:** SFA-permitted ✅ — used as an iron supplement/mineral source.
- **Resistant Dextrin:** A dietary fibre ingredient (prebiotic fibre), not a regulated additive — no E number, no SFA check needed.
- **Flavourings:** Generic label — cannot identify specific flavouring compounds; SFA check not possible.
- **Vitamins (C, B3, D, B6, B2, B12):** Added as nutritional fortification, not food additives in the regulatory sense. Riboflavin (B2) carries E101 when used as a colourant, but here it's explicitly declared as a vitamin supplement — standard and expected.
**Step 4 — Healthier Choice Symbol.** HCS certified ✅ (MILO 3in1 Activ-Go appears multiple times in the HPB dataset — `check_hcs`).
**Step 5 — Sugar content reality check.** Each 27g sachet contains ~13.8g total sugars (~10g added sugars). That's more than **half the sachet's weight is sugar**, and exceeds the WHO daily added-sugar guideline of 25g in less than one cup. This is label-read, not a tool finding.
---
## Verdict
### 🟢 GREEN — No Concern
- **All three declared additives are SFA-permitted** (Dicalcium Phosphate E341, Disodium Phosphate E339, Ferric Pyrophosphate) — `check_sfa_additive` ×3.
- **Carries the Healthier Choice Symbol** — HPB has certified MILO 3in1 Activ-Go — `check_hcs`.
- **Vitamins and minerals fortified** — B2, B3, B6, B12, C, D, plus calcium and iron — label.
- **Contains barley malt extract** — a whole-grain source of energy — label.
- **Resistant Dextrin** (prebiotic dietary fibre) is a functional ingredient, not a concern — label.
### 🟡 AMBER — Worth Knowing
- **Palm Oil present** — a saturated fat source. Saturated fat is 1.2g per sachet (~4.4g per 100g of powder). Not alarming at one serving, but worth knowing if you have multiple cups a day — label.
- **"Flavourings" are not specified** — cannot determine whether natural or artificial. No SFA check was possible — label.
- **Nutri-Grade does not apply here** — this is sold as a powder. If you reconstitute it and compare the drink to ready-to-drink alternatives, it would likely land in Grade C or D territory based on sugar content, but no regulatory grade is required on the packet.
### 🔴 RED — Genuinely Concerning
- **Sugar is the #1 ingredient** — it appears first in the list, meaning it is the dominant ingredient by weight. Each sachet delivers **~13.8g total sugars / ~10g added sugars** — label. That's 40% of the WHO's recommended 25g/day added-sugar limit in a single cup. If you're drinking 2–3 sachets a day (as Nestlé's own label suggests for daily energy), you are consuming **20–30g of added sugar from MILO alone**, likely exceeding the daily limit.
- **Marketing framing vs. nutritional reality:** The packet prominently says "Nourishing Energy" and shows vitamins, but the sugar load is the dominant nutritional feature. The fortification (vitamins, minerals) does not offset this — label.
**Bottom line for a Singapore shopper:** MILO 3in1 is safe from an additives and regulatory standpoint — everything checks out with SFA and HPB. But it is a **sugar-heavy product** dressed up in health language. Treat it as an occasional treat rather than a daily nutritious breakfast drink, especially for children.
Where it bites. There's no native structured output, so you get text and either parse it or prompt hard for the shape, which is the biggest day-to-day gap relative to the OpenAI SDK. Schemas are manual, so there's more to write and keep in sync with each function. Model wiring happens through the environment, and the proxy you aim at must offer an Anthropic-format /v1/messages endpoint, because an OpenAI-compatible endpoint by itself isn't enough. Treat the allowed_tools list as pre-approval, never as a sandbox: it means "skip the confirmation prompt for these," not "the agent may see only these," and newcomers reading it as a whitelist get a surprise. Forget the mcp__sg__ prefix and the tool just goes unapproved, with no error. The footprint is also heavier, since you launch the Claude Code runtime under the hood and aren't making a bare API call.
Pick it when the agent has to operate a machine. That means reading and writing files, running shell commands, editing a codebase or browsing, any task where the built-in Claude Code tools spare you from building a sandbox. It's also the natural choice if you already live in MCP, or you're making something that should feel like Claude Code. The streaming loop is easy to watch, which is nice for long-running agents.
Pass on it when you want a slim, typed function-calling loop that returns a structured object, because that's where the OpenAI SDK is at home. Pass if you can't provide an Anthropic-format endpoint or if a small dependency footprint matters. A plain "call three tools, return a Pydantic object" job like this one needs far less machinery.
OpenAI Agents SDK
OpenAI's SDK is a thin, deliberately Pythonic wrapper around the loop, and it commits fully to the language itself. Type hints turn into tool schemas, docstrings into tool descriptions and Pydantic models into the output contract. Nothing prescribes a project layout, there's no separate runtime and no MCP detour. You write functions and models, build an Agent and give it to Runner.run. The aim is the shortest distance between a Python function and a working agent.
Its best trick is the typed round trip. Specify output_type=YourModel and what comes back is a validated Python object and not a blob you're hoping is JSON; the SDK writes the prompt, parses the reply, validates it and goes back to the model when the shape is off. Schemas that come for free from type hints, plus a no-cost tracing dashboard on OpenAI's own backend, make this the quickest way to reach a structured, observable agent. When the task is "take this input, give me a typed result," no other kit is as direct.
Tools need no schema authoring whatsoever:
@function_tool
async def check_sfa_additive(additive: str, e_number_hint: str = "") -> str:
"""Check whether a food additive is permitted by the Singapore Food Agency.
Looks the additive up in the full SFA permitted-additives list (parsed from
the official SFA PDF). Accepts either an E-number or a plain-English name as
printed on a Singapore label.
Args:
additive: An E-number ('E211', 'e211', 'en:e211') OR an additive name
('Sodium Benzoate', 'Soy Lecithin', 'MSG').
e_number_hint: Optional. If you pass a name and also know its E/INS
number, supply it here. If the name isn't recognised but the number
is, the mapping is REMEMBERED so the name resolves directly next time. """
...
The decorator reads the type hints and the Args section of the docstring and builds the JSON schema, so there's nothing to maintain by hand.
Where it really pays off is the output. The verdict is declared as a Pydantic model:
class NutritionVerdict(BaseModel):
reasoning: str # declared first: the model works out loud before committing
product_name: str
summary: str
green: list[Finding]
amber: list[Finding]
red: list[Finding]
recommendation: str
With output_type=NutritionVerdict, a run hands back a validated object, with no parsing step and no crossed fingers. Putting reasoning first is a neat trick to borrow: structured output doubles as a scratchpad, so the model thinks aloud before committing to findings, and you keep the tidy object and the rationale together.
Model wiring and image input are the tidiest of the three:
client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY)
model = OpenAIChatCompletionsModel(model=OPENAI_MODEL, openai_client=client)
agent = Agent(name="SG Nutrition Investigator", model=model,
instructions=INSTRUCTIONS, output_type=NutritionVerdict,
tools=[recall_product, check_sfa_additive, check_hcs, calculate_nutri_grade])
user_input = [{"role": "user", "content": [
{"type": "input_text", "text": "Investigate this nutrition label."},
{"type": "input_image", "image_url": _data_url(image_path)}, # base64 data URL
]}]
result = await Runner.run(agent, input=user_input)
verdict: NutritionVerdict = result.final_output
That's an AsyncOpenAI client aimed at the proxy, wrapped in OpenAIChatCompletionsModel, with an input_image item carrying the photo. No fuss.
Gotchas. Tool calls fire in parallel by default, so the model happily starts several check_sfa_additive calls together. Ours are async and have no side effects, which makes it a win. A tool that mutates shared state, keeps a non-reentrant connection or depends on order will misbehave, and the failures show up intermittently and depend on timing, the worst kind. Either make tools concurrency-safe or restrain the model.
Tracing has its own catch. The dashboard is superb, but traces go to OpenAI's own ingest endpoint, and behind a third-party proxy the key issued by the proxy can't authenticate there. So set_tracing_disabled(True) goes in and your best observability tool disappears with one line. Away from api.openai.com, count on OpenTelemetry or home-grown logging. Smaller traps follow. Behind a proxy you need OpenAIChatCompletionsModel, since the default Responses API path targets OpenAI's endpoints and the wrong choice produces baffling errors. The output_type schema has to satisfy strict-mode rules, so models with deep nesting or many unions can set off validation retries; Finding and NutritionVerdict stay flat for that reason. And no computer-use tools ship in the box, so no filesystem, shell or browsing unless you add them.
Here's the output for the Milo sachet:
sausheong openai-demo % uv run python main.py ../milo.jpeg
Investigating image: ../milo.jpeg
────────────────────────────────────────────────────────────
Product: Nestlé MILO 3 in 1 Activ-Go
Reasoning: I read the sachet as **Nestlé MILO 3 in 1 Activ-Go**. The front says “goodness of malt, milk & cocoa” and the back says “MILO Ionic Food Drink,” so this is a **powdered drink mix**, not a ready-to-drink beverage. Because it is not sold as a beverage with per-100ml nutrition for the prepared drink as consumed, I did **not** calculate Nutri-Grade; that tool is only for beverages.
I first checked whether this product had been investigated before. A prior record existed, and it broadly matched what I can read here: a sweetened powdered malt-cocoa-milk drink sachet with permitted phosphate additives, relatively high in sugar for a single sachet, and not found in the HCS database. I used that only as background and then re-read this label fresh.
From the nutrition panel, I can read:
- Serving size: **1 sachet (27g)**
- Servings per package: **1**
- Per 100g / per serving shown side by side
- **Total sugars: 50.1g per 100g; 13.8g per serving**
- **Saturated fat: 0.5g per 100g; 1.2g per serving**
This is a powder, so the relevant direct reading is per 100g, not per 100ml.
I can read the ingredient list as:
**Sugar, Protomalt™ Malt Extract (contains barley), skimmed milk/cow’s milk, cocoa, palm oil, minerals (dicalcium phosphate, disodium phosphate), fibre (pyrophosphate), resistant dextrin, flavourings, vitamins (C, B3, D, B6, B2, B12).**
For additives, I checked each identifiable additive individually where possible:
- **Dicalcium phosphate**: checked and found **permitted by SFA**. That lands in GREEN.
- **Disodium phosphate**: checked and found **permitted by SFA**. That also lands in GREEN.
- The label also contains the broader terms **“phosphates”** and **“pyrophosphate”/“fibre (pyrophosphate)”** in wording around ingredients, but the image does not clearly identify a specific additive code for the pyrophosphate entry, and the generic umbrella term “phosphates” is not reliable enough on its own. I therefore do not treat the generic term as confirmed permitted just from the broad word alone. I only rely on the specifically named phosphates I could check.
- **Flavourings** is too generic to identify a specific additive, so I could not check it individually.
- The vitamins and minerals are nutrients rather than red-flag food additives in the SFA additive sense.
I also checked for the **Healthier Choice Symbol** using the product name. It was **not found** in the HCS database.
Main nutritional takeaways from the label:
- Sugar is the **first ingredient**, which usually means it is the largest ingredient by weight.
- The product contains **50.1g sugar per 100g** powder, and **13.8g sugar per 27g sachet**. That is quite a lot for one small sachet drink.
- Saturated fat is **1.2g per sachet**, which is not extreme, but the bigger concern here is sugar.
So the overall picture is: the identifiable phosphate additives appear permitted, but nutritionally this is still a **sweetened convenience drink mix** rather than a lower-sugar everyday option.
Summary: This is a powdered MILO drink sachet, not a ready-to-drink beverage. The clearly identifiable phosphate additives on the label are permitted by SFA, but sugar is the first ingredient and the product contains 50.1g sugar per 100g, or 13.8g per 27g sachet. It was not found in the Healthier Choice Symbol database.
🟢 Dicalcium phosphate is permitted by SFA. [check_sfa_additive]
🟢 Disodium phosphate is permitted by SFA. [check_sfa_additive]
🟢 This is a powdered drink mix rather than a ready-to-drink beverage, so Nutri-Grade was not applicable here. [label]
🟡 Generic terms such as “flavourings” and the unclear “fibre (pyrophosphate)” wording are not specific enough to confirm exact additive identities from the image alone. [label]
🟡 The product was not found in the Healthier Choice Symbol database. [check_hcs]
🟡 Saturated fat is 1.2g per 27g sachet (0.5g per 100g listed on the label panel image). [label]
🔴 Sugar is the first ingredient on the label. [label]
🔴 Total sugars are 50.1g per 100g and 13.8g per 27g sachet, which is high for a small single-serve drink mix. [label]
Recommendation: Fine as an occasional sachet drink if convenience matters, but not the best everyday choice if you are trying to reduce sugar. If you drink MILO often, consider using plain MILO powder with your own milk and less sugar, or choose a lower-sugar version.
Choose it when the task is structured in and structured out, such as classification, extraction, scoring or verdicts, anywhere a typed object beats prose and you want minimal Python. Teams that love type hints will enjoy it, and with OpenAI's backend the free tracing makes debugging a multi-step agent pleasant.
Skip it when your tools can't be made concurrency-safe and you'd rather not wrestle the parallel default, when you need observability but sit behind a non-OpenAI gateway (the tracing disappears), or when the agent must use filesystem and shell tools out of the box, which is Claude's turf. For several cooperating agents under explicit control flow, ADK fits better, and that's the third kit.
Google's Agent Development Kit (ADK)
Of the three, ADK is the one that's truly a framework, opinionated where the others stay lean. It brings agents, sessions, a Runner, a SessionService and explicit multi-agent workflows through SequentialAgent, ParallelAgent and loop agents. A prescribed project layout lets its tooling find your agent, and adk web opens a local chat interface with an event inspector. Tools are plain Python functions documented in Google-style docstrings, with neither decorators nor schema dicts, and the LiteLlm wrapper makes it the most provider-agnostic option.
The pitch is architecture for systems and not scripts. When "the agent" really means several agents, such as a planner handing off to workers, a sequential pipeline or a parallel fan-out, ADK offers those as composable first-class parts and spares you hand-rolled control flow. Sessions are built in, the debug UI is the best of the group and the provider-agnostic model layer keeps you from marrying one vendor. It's built to grow into a complex, stateful, multi-agent application.
Tool definitions are the plainest here:
async def check_sfa_additive(additive: str, e_number_hint: str = "") -> dict:
"""Check whether a food additive is permitted under Singapore Food Agency rules.
Looks the additive up in the full SFA permitted-additives list (parsed from
the official SFA PDF). Accepts either an E-number or a plain-English name as
printed on a Singapore label.
Args:
additive: An E-number ('E211', 'e211', 'en:e211') OR an additive name
('Sodium Benzoate', 'Soy Lecithin', 'MSG').
e_number_hint: Optional E/INS number for when you pass a name. If the
name isn't recognised but the number is, the mapping is REMEMBERED
so the name resolves directly next time.
Returns:
dict with the input, resolved e_number/name, permitted status, schedule
and any consumer note. found=False if it is not in the SFA list.
"""
...
The schema is built from that Google-style Args and Returns docstring, which means its quality carries real weight. A vague docstring directly hurts the model's tool selection, so the docstring is the actual contract.
For adk web to find your root_agent, the project needs a package layout:
adk-demo/
sg_nutrition_investigator/
__init__.py # from . import agent
agent.py # tools + root_agent
main.py # standalone Runner + InMemorySessionService
root_agent = LlmAgent(
name="sg_nutrition_investigator",
model=LiteLlm(model="openai/" + GEMINI_MODEL, api_base=BASE_URL, api_key=API_KEY),
instruction=INSTRUCTION,
tools=[FunctionTool(recall_product), FunctionTool(check_sfa_additive),
FunctionTool(check_hcs), FunctionTool(calculate_nutri_grade)],
)
# image input — the cleanest of the three:
message = types.Content(role="user", parts=[
types.Part(text="Investigate this nutrition label."),
types.Part.from_bytes(data=Path(image_path).read_bytes(), mime_type=mime),
])
The model is routed through ADK's LiteLlm wrapper, where an openai/ prefix makes litellm talk the OpenAI-compatible protocol to the proxy. Passing images with types.Part.from_bytes is the neatest of the three, and adk web offers the nicest built-in development experience in this comparison.
Gotchas. Most concern weight. Outside adk web, you wire a Runner, a SessionService and explicit app, user and session IDs before the first message goes out, a lot of ceremony next to the few lines the others need. The layout is load-bearing as well. The from . import agent line in __init__.py is how adk web discovers root_agent, and any departure from the structure breaks discovery. litellm drags in probes for unused backends (harmless bedrock and sagemaker "no module named botocore" warnings), ADK piles an [EXPERIMENTAL] schema warning on top and you end up muting both at import. Structured output is fussier too, because Gemini's response_schema is touchier behind a proxy than OpenAI's output_type, so this demo uses a prompted text verdict. Fairly, that's as much the proxy as ADK. One last point: on the LiteLlm plus Gemini-flash path, the agent narrated its recall less than the others given the same prompt, which shows that behavior depends on model and wiring, not only on the SDK.
The output for the Milo sachet:
sausheong adk-demo % uv run python main.py ../milo.jpeg
Investigating image: ../milo.jpeg
────────────────────────────────────────────────────────────
→ tool: recall_product({'product_name': 'Nestle MILO 3 in 1'})
→ tool: check_sfa_additive({'e_number_hint': 'E341', 'additive': 'Dicalcium Phosphate'})
→ tool: check_sfa_additive({'additive': 'Disodium Phosphate', 'e_number_hint': 'E339'})
→ tool: check_sfa_additive({'additive': 'Ferric Pyrophosphate'})
→ tool: check_hcs({'product_name': 'Nestle MILO 3 in 1'})
→ tool: calculate_nutri_grade({'saturated_fat_per_100ml': 0.5, 'sugar_per_100ml': 6.1})
## Reasoning
We investigated the label of **Nestle MILO 3 in 1 (with Activ-Go)**. Here is our step-by-step analysis:
1. **Product Categorisation & Reconstitution**:
* This is a dry beverage powder sold in single-serving 27g sachets.
* The nutritional information panel provides two columns: "Per Serving 27g" (the dry sachet) and "Per 100ml*" (as reconstituted by adding 200ml of hot water).
* For the **Singapore Nutri-Grade** system, the grading is calculated based on the beverage *as reconstituted* according to the directions.
2. **Sugar & Saturated Fat Analysis (Per 100ml prepared)**:
* **Total Sugars**: **6.1g per 100ml** (of which 4.8g is Added Sugar).
* **Saturated Fat**: **0.5g per 100ml**.
* Feeding these values into the Nutri-Grade calculator yields **Nutri-Grade C** (due to the sugar content being > 5g/100ml). Grade C beverages are classified as "less healthy" and must display the Nutri-Grade label at retail.
3. **Ingredients & Additives Analysis**:
* **Sugar is the #1 Ingredient**: Refined sugar is listed first on the ingredients list, meaning it is the largest ingredient by weight in this packet—even ahead of malt extract and milk powder.
* **Additives**:
* **Dicalcium Phosphate** (E341(ii)) and **Disodium Phosphate** (E339(ii)) are SFA-approved food additives used as stabilizers and emulsifiers.
* **Ferric Pyrophosphate** is an SFA-approved mineral nutrient used to fortify the drink with Iron.
* **Healthier Choice Symbol (HCS)**: The packet displays the red HCS logo. This is because it is formulated to be "25% lower in sugar" *compared to regular malted beverages*. However, in absolute terms, it still carries enough sugar to land in Nutri-Grade C.
---
## Verdict
### 🟢 Green Findings
* **Fortified with Vitamins & Minerals** `[label]`: Contains B-vitamins (B2, B3, B6, B12), Vitamin C, Vitamin D, Calcium, and Iron (via *Ferric Pyrophosphate* `[check_sfa_additive]`).
* **Contains Soluble Fibre** `[label]`: Features *Resistant Dextrin*, a prebiotic soluble dietary fibre (1.1g per serving) that supports gut health.
* **SFA-Approved Additives** `[check_sfa_additive]`: Phosphates used for stabilizing and emulsifying (*Dicalcium Phosphate* and *Disodium Phosphate*) are fully permitted.
* **Healthier Choice Certified** `[check_hcs]`: Carries Singapore's HCS logo for having at least 25% less sugar than standard malted chocolate drinks.
### 🟡 Amber Findings
* **Nutri-Grade C** `[calculate_nutri_grade]`: When prepared as instructed, it gets a **Grade C** because of its **6.1g of sugar per 100ml**. Grade C drinks are in the "less healthy" bracket.
* **Contains Palm Oil** `[label]`: Palm oil is added to give a rich mouthfeel, contributing **1.2g of saturated fat per serving** (0.5g/100ml).
### 🔴 Red Findings
* **Sugar is the Main Ingredient** `[label]`: Sugar is listed as the very first ingredient on the label, which means there is more plain sugar by weight than malt extract (*PROTOMALT*) or milk powder.
* **High Sugar Load per Cup** `[label]`: A single small 27g sachet contains **13.8g of total sugar** (equivalent to about **3 teaspoons of sugar**), of which **10.8g** (approx. 2.5 teaspoons) is added refined sugar. Drinking multiple cups a day can quickly max out your recommended daily limit for added sugars.
Choose ADK when you're constructing a system: multiple cooperating agents, explicit sequential, parallel or looping workflows, genuine session state and room to swap models across providers. The adk web inspector pays for itself during development, and the architecture repays its upfront cost as the project expands.
Skip it when there's a single plain agent. The runner and session boilerplate, plus the prescribed layout, are overhead you'll never earn back. For the quickest typed result use the OpenAI SDK, and for a computer-using agent with built-in tools use Claude's. ADK justifies its complexity at scale, and below that it's more framework than you need.
Running the demos
With all three agents built, here's how to run them. Each is a uv project, and each reads the root .env with the proxy key, base URL and three model names:
cd openai-demo # or claude-demo, or adk-demo
uv run python main.py ../milo.jpeg
uv run python main.py ../hl.jpeg
To regenerate the SFA table from the PDF, run uv run --with pdfplumber python build_additives.py. Feed it your own label photo by passing the path. The two bundled labels are real Singapore products and score about as expected: the milk gets Nutri-Grade B, and the Milo, a powder, comes out as C once it's made up.
Going beyond a single shot
A demo that makes one clean pass hides the interesting parts. Three additions, identical in shape across the SDKs, make the agents more realistic, and each says something about the SDK underneath.
Real data. The additive check is no seven-entry toy dictionary. build_additives.py parses the official SFA PDF, whose three tables use three different column layouts (an adventure of its own), into roughly 540 additives. At load time, each demo builds a normalized name index so that a label's "monosodium glutamate" matches the PDF's formal "Monosodium L-glutamate". An E/INS number index keyed on the base number lets "500" still find "500(ii)". A short map handles genuine slang absent from the PDF, such as "MSG" and "vitamin C". Whatever truly isn't on the list comes back as a plain "not on the list" and never a false "permitted".
Memory and learning. These two are best seen together:
- Explain. Each agent shows its reasoning, as a typed
reasoningfield in OpenAI's case and a leading## Reasoningsection for Claude and ADK. - Recall. A
recall_producttool runs first and brings up any earlier verdict on the same product, and each run stores its own. Scan one label twice and the second pass checks itself against the first conclusion. - Learn. If a label prints a name the table doesn't recognize and the model supplies the correct number through
e_number_hint, the name-to-number pairing is saved in a smallagent_memory.json. Next run, that name resolves without asking the model.
That's learning at the system level. Weights never change, yet the agent refines its own data and tools from experience. It's the practical, reachable kind of learning, unlike fine-tuning, which none of these kits performs at runtime. Each does include a native session layer you'd use for production memory (OpenAI's SQLiteSession, Claude's session store, ADK's SessionService), and the JSON file simply keeps the demo portable and easy to inspect.
The more labels it reads, the better it gets. A photo whose additive names the model maps to numbers teaches the lookup a fresh alias, and every investigated product becomes something the next run can recall. When "disodium phosphate" first appears, the run relies on the model to map it to E339; after that, the deterministic table just knows, so it's faster, cheaper and repeatable with no model call. Across a shelf of products, agent_memory.json grows into an alias dictionary and a verdict history the agent compiled itself. Point a fleet at one shared memory file and a name learned by one agent becomes known to all. The model stays frozen, and the agent doesn't.
A final detail from the real runs: when asked to pass an E-number with a name, all three models complied reliably, turning "Disodium Phosphate" into E339 without help. That's the loop doing its job, with general model knowledge covering what the deterministic lookup misses and the system remembering the result.
Who provides what
With all three agents built, run and extended, the whole harness can be laid out and marked by what each SDK actually supplied against what had to be built by hand. Picture a grid where green means the SDK provides it, amber means partial and red means you build it yourself. The components fall into the same three layers: Development for building the agent, Runtime for operating one and Control Plane for governing a fleet. The SDKs live in the first.
Look across the Development layer and nearly everything is green; the kits agree almost completely on building one agent. Look at the two layers beneath and the pattern reverses: a few green or amber cells at the edges (ADK's workflows and evaluation, OpenAI's tracing) float on a sea of red shared by all three. That contrast is the real finding. On building an agent they're in agreement, and running and governing it is left for you to handle.
The gaps in the SDKs
The Development layer is what the SDKs make easy. The Runtime and Control Plane are mostly missing from them, and realistically those aren't what the SDKs are meant to deliver.
Begin with the gaps that surfaced during the demo build alone. One is the tool gateway, which the runtime layer distinguishes from plain tool invocation. The harness calls your tool faithfully but does nothing to make it dependable. The data.gov.sg HCS API, for example, sometimes answers 503 with an HTML body, and a naive response.json() brought down the entire run (a lesson learned painfully). A _safe_json helper, ten-second timeouts and "not on the list" fallbacks fixed it, so a shaky upstream turns into a finding and no longer kills the agent. Retries, backoff and error shaping inside tools are all yours, the very chores a gateway is meant to absorb.
A second gap is managed memory. All three include a session layer, which is conversation state on one node, but which isn't the same as an application keeping facts in storage that a whole fleet can reach. The agent_memory.json built here (the learned aliases, the recall_product history, the write-back that sharpens the agent with every label) is domain logic sitting on top, and "improve the more you run" isn't something a harness supplies. Grounding in real data is similar. Parsing the SFA PDF into 540-odd structured additives, building the normalized index and the base-number lookup: none of that falls within an SDK's remit, and feeding an agent real data is a pattern you wire up, not a switch you flip.
Concurrency safety for tools is worse than simply unhandled, since the harness itself creates the hazard. By default the OpenAI SDK runs independent tool calls in parallel, and making your tools safe under that is up to you. One more mundane item: rendering the verdict and saving it anywhere is ordinary application code that no SDK touches.
Looking at the runtime layer, the agent first needs hosted execution, a way to be invoked from something other than a terminal session, with many runs in flight and scaling underneath. It needs a workload identity, a credential presented to tools so calls are made as a named workload and not through an API key hidden in an environment variable. And it needs durable, resumable loops, so a crash halfway continues instead of restarting the whole run. None of these exists in a script on a laptop, and none is something the SDK claims to provide. The runtime must also scale the agents as load rises.
The control plane is where all three do close to nothing. Evaluation is the first case: "it ran without error" doesn't mean "it was right", and measuring agent quality properly is largely up to you. ADK is the partial exception thanks to its eval framework, while the other two leave the question open.
Next, guardrails and prompt-injection defense. Each tool result is untrusted input returning to the model, and a label photo or an API response could contain an instruction like "ignore your rules". Coverage is uneven and nothing is automatic, so the threat model belongs to you.
Then comes agent identity with permission scoping. The kits let you set a persona, yet they're silent on the agent's standing as a directory principal and on what it's allowed to reach, so tying it to a real owner and limiting its reach falls on you.
Connecting to one MCP server is a different story, as all three provide a client for it. A tool and MCP registry, though, which catalogs and versions tools and operates a fleet of MCP servers with discovery, pooling, per-server credentials and health checks, is once more left to you.
The identical hole shows up one scope higher in the agent registry, where agents are published so others can discover and call them at runtime and not through hardcoded wiring. ADK is a partial exception, supporting A2A and agent cards, while the other two leave it to you.
Finally there's cost and quota governance, an audit log proving what the fleet did, governance of the shared data store and prompt versioning. The system prompt is merely a string you own and the model is stochastic, so pinning, diffing and rolling back prompts is a discipline you must enforce yourself.
None of this is criticism. Stopping at the agent loop and the plumbing next to it is a sensible place to draw the line. It does mean that choosing an SDK settles perhaps half of what a production agent requires.
So which SDK should you use?
To return to the kits themselves, here's each in one sentence. Pick the Claude Agent SDK when the agent should operate a computer, handling files, shell, code and browsing; it's the most mature runtime, paid for with manual schemas, no typed output and environment-variable wiring. Pick the OpenAI Agents SDK for structured input and output with minimal Python; it offers the quickest typed round trip, provided you mind the parallel-tool default and the loss of tracing behind a proxy. Pick Google ADK when several agents must cooperate through explicit workflows backed by genuine sessions; its dev UI is the strongest and its model choice the widest, though a simple lone agent will find it heavy.
Underneath, these are different tools and not three runners on one scale. The same agent could be built in all of them because the hard core, the reasoning loop over tools, is common. The scaffolding around that core is where they differ, and it should decide your choice.
What building it three times made clear
Building the agent three times made one conclusion obvious: the agent loop is a solved, commodity problem, and all three kits deliver it cleanly. Real differences lie in the scaffolding, with hand-written versus inferred schemas, free text versus typed output, and a bundled runtime versus a thin client versus a full framework. Those are questions of fit and not of quality. Fit the SDK to the task, whether that's Claude for operating a computer, OpenAI for a typed result or ADK for multi-agent systems, and any of the three will serve you well.
The lasting lesson is the boundary, and the three layers show where it falls. The kits own the Development layer and give it to you within an afternoon. The Runtime and Control Plane, which means operating one agent for real and governing a fleet, are yours, and that's where most engineering effort sits. The investigator became useful only after adding what no SDK supplies: the parsed SFA table for grounding, the failure handling a tool gateway would normally own and the memory that sharpens it with each label. So choose an SDK for its Development-layer fit, then budget for the two layers below.
A larger story lurks just past this one, and it sits in those lower layers. With single-agent building now a commodity, the contest has moved down the stack to who runs agents and who governs them. New products are forming there: runtimes like AWS Bedrock AgentCore and Google's Gemini Enterprise Agent Runtime, which can host whatever any of the three kits produced, plus control planes such as Microsoft's Agent 365 and Google Enterprise that register, identify and police whole fleets. They sell, nearly line for line, the red cells of that grid. The snag is that every cloud wants all three layers, so the SDK you choose today leans you toward one vendor's runtime and governance tomorrow.