Build Social Spark
An ADK agent that turns an idea into a researched, on-brand, illustrated social post, approved by you, then published for real, all wrapped in a web app you didn't have to build.
Google Cloud's Gemini Enterprise Agent Platform groups agent work into four pillars: Build, Scale, Govern, Optimise. This lab is Build β designing and composing an agent: the Agent Development Kit (ADK) itself, Model Garden (the Gemini models your agents call), function tools, Agent Skills, and MCP toolsets. Multi-agent decomposition (an orchestrator routing to specialist sub-agents) and connecting an Agent Garden template or a RAG Engine-backed memory agent are Build-pillar concepts too β you'll see the fuller version of that in the tagged reference (below). None of what you build today is about where it runs (that's Scale), who's allowed to do what (that's Govern), or whether it's any good (that's Optimise) β those are Labs 2β4 in this series.
Every step has three parts: the concept, a prompt you can give your coding agent (Antigravity, Claude Code or AGY CLI β the starter repo's AGENTS.md tells it the project conventions), and a checkpoint that proves the step worked. The code blocks are there to read and compare against, or to type by hand if you prefer; the checkpoint is what counts, not matching the code. Review your agent's plan before accepting it. One step, marked clearly, is designed to fail the first time.
Today you write a real ADK backend: an orchestrating agent with a hand-written tool, four Agent Skills (you author one yourself), an isolated research sub-agent, and a live MCP connection to Buffer that actually publishes what you approve. Then you wire up a CopilotKit frontend that's given to you, but you'll open the two files that make it talk to ADK, because that connection is the whole point of Step 8.
What's given vs. what you build
| File / piece | Who does it |
|---|---|
tools.py (Gemini image call) | given already in the repo |
skills/post-formatter/, platform-style/, poster-style/ | given already in the repo |
skills/brand-voice/SKILL.md | you write Step 4 |
backend/social_poster/agent.py | you write Steps 2, 3, 4, 5, 6, 7 |
backend/main.py (the AG-UI bridge) | given already in the repo; in Step 8 you read it (it's the part that matters) |
frontend/ (Next.js + CopilotKit app) | given already in the repo; in Step 8 you'll read two files in it |
mcp/linkedin_server.py | given already in the repo, Step 7 only |
Before you start
- Completed the pre-camp setup guide: Python 3.14+,
uv, Node 20+, the Google Cloud SDK authenticated against your DevCamp project, andgoogle-adkinstalled. - A terminal, an editor, and roughly two hours. Every step has a time estimate in the sidebar; total for the core path (Steps 1β6, 8β9) is about 1h45; Step 7 (LinkedIn) adds another 15 min and is entirely optional.
- A free Buffer account for Step 6 (takes two minutes, no card needed for the free tier).
Skip Step 7 entirely, and treat Step 8 (wiring the frontend) as a guided read-through rather than something everyone types from scratch; the payoff there is understanding main.py's four ADK-relevant lines, not retyping 400 lines of React you didn't write.
Ask in your buddy group or Discord first β someone might have already solved it. The reference checkpoint for this lab, the pillar-build tag on the ai-devcamp-labs repo, is published the day after the session, on purpose: it's the answer key for catching up before Lab 2, not something to copy from during the workshop. It deliberately contains no Govern-pillar code yet.
Set up your workspace
Clone the starter repo, which holds the plumbing that isn't the point of today, and get your environment talking to Agent Platform.
The starter repo contains only what you'd otherwise copy-paste without learning anything: pinned dependencies, the Gemini image tool, three of the four Agent Skills, the web frontend (given as-is; this camp builds the backend), the LinkedIn MCP server, and an AGENTS.md that tells your coding agent the project's conventions. backend/social_poster/agent.py is empty. Everything agent-shaped is yours to write.
git clone https://github.com/sonikajanagill/ai-devcamp-labs.git
cd ai-devcamp-labs
cp .env.example backend/social_poster/.env
uv sync
Edit backend/social_poster/.env: set GOOGLE_CLOUD_PROJECT to your DevCamp project id (no service-account key file needed anywhere β gcloud auth application-default login from Step 2 of the setup guide is enough). Leave everything else; you'll fill in BUFFER_API_KEY in Step 6.
You should now have
uv sync completes with no errors, and uv run python -c "import google.adk" prints nothing (silence = success).
The smallest possible agent
Everything in ADK starts with one object: an Agent, a model, an instruction, and optionally tools.
In backend/social_poster/agent.py, create an ADK Agent named social_poster on model gemini-2.5-flash, with a one-line description and an instruction that it helps the user turn an idea into a social media post. Expose it as root_agent. No tools yet.
from google.adk.agents import Agent
root_agent = Agent(
name="social_poster",
model="gemini-2.5-flash",
description="Helps the user turn an idea into a polished social media post.",
instruction="You help the user turn an idea into a social media post.",
)
uv run adk web backend
Open http://localhost:8000, select social_poster, say hi. That's a complete, working ADK agent: no tools yet, just a model and an instruction.
The agent replies in the ADK web chat. Leave adk web running; you'll re-test after every step.
Give it a tool
The easiest way to hand ADK a tool: write a plain Python function. ADK inspects its signature and docstring and wraps it automatically: no decorators, no schema to write by hand.
Add the existing generate_image function from backend/social_poster/tools.py as a tool on root_agent. Extend the instruction so that when the user asks for an image the agent calls it with a detailed visual description and reports where the file was saved.
tools.py (already copied in) has generate_image(prompt: str) -> dict, which calls Gemini's image model and writes the result to gallery/. Wire it in:
from google.adk.agents import Agent
from .tools import generate_image
root_agent = Agent(
name="social_poster",
model="gemini-2.5-flash",
description="Helps the user turn an idea into a polished social media post.",
instruction="""You help the user turn an idea into a social media post.
If the user asks for an image, call generate_image with a detailed visual
description and tell them where the file was saved.""",
tools=[generate_image],
)
Restart adk web and try: Draft something about remote work, and add an image. Watch the Trace tab: generate_image fires as a real tool call, and a PNG lands in gallery/.
The Trace tab shows a generate_image tool call with your description as its argument, and a new PNG exists in gallery/. If the agent describes an image in text instead of calling the tool, tighten the instruction and try again.
Teach it Agent Skills
A Skill is a folder with a SKILL.md file: YAML frontmatter (name, description) plus markdown instructions. The agent decides when to load one via a tool call; the content isn't in the system prompt until it's actually needed. That's progressive disclosure: keep the default context small, pull in detail only when relevant.
- The default prompt stays small. Only each skill's name and one-line description are always in context. The full rules load when a draft needs them, so adding a fifth or a tenth skill costs about a line each until it is used.
- Behaviour lives in files, not code. Tone, structure and platform rules are markdown. Anyone can change them, review the change in a pull request, and never open
agent.py. - Each concern stays separate. One long instruction prompt makes rules crowd each other. A skill keeps formatting, platform rules, tone and image style apart, and the agent picks what applies.
- A skill is just a folder. You can copy one into another agent or project.
- It is governance you can read. A skill is a reviewable file that constrains what the agent writes. Lab 3 comes back to this.
x.md never loads.Create skills/brand-voice/SKILL.md with frontmatter name: brand-voice and a description saying to load it whenever drafting, then tone rules (casual, British English, short sentences) and a banned-clichΓ©s list. Then build a SkillToolset with load_skill_from_dir for post-formatter, platform-style, brand-voice and poster-style from the skills/ folder at the repo root, add it to root_agent's tools, and update the instruction to load the relevant skills before drafting and the poster-style skill before generating an image.
Open the three skills you copied in and read them; that's the whole format:
skills/post-formatter/SKILL.mdβ structural rules (hook line, paragraph limits, hashtags)skills/platform-style/SKILL.mdβ points at a second-level reference file per platform (references/linkedin.md,references/x.md)skills/poster-style/SKILL.mdβ visual style rules forgenerate_imageprompts (you'll wire this in properly once images route through the skill)
Now write one yourself: brand-voice
Create skills/brand-voice/SKILL.md:
---
name: brand-voice
description: Tone-of-voice and language rules for all written content. Load whenever drafting or revising a post.
---
# Brand Voice
## Tone
- Casual and warm, like explaining something interesting to a colleague.
- British English throughout: organise, colour, whilst, learnt.
- Short sentences. Active voice.
## Banned clichés
Never use: "game-changer", "unlock the power of", "seamless", "leverage"
(as a verb), "I'm thrilled to announce".
The name: in the frontmatter must exactly match the directory name, or loading it raises an error. brand-voice/ β name: brand-voice.
Wire all four skills in
import pathlib
from google.adk.agents import Agent
from google.adk.skills import load_skill_from_dir
from google.adk.tools.skill_toolset import SkillToolset
from .tools import generate_image
SKILLS_DIR = pathlib.Path(__file__).resolve().parents[2] / "skills"
skill_toolset = SkillToolset(
skills=[
load_skill_from_dir(SKILLS_DIR / "post-formatter"),
load_skill_from_dir(SKILLS_DIR / "platform-style"),
load_skill_from_dir(SKILLS_DIR / "brand-voice"),
load_skill_from_dir(SKILLS_DIR / "poster-style"),
],
)
root_agent = Agent(
name="social_poster",
model="gemini-2.5-flash",
description="Helps the user turn an idea into a polished social media post.",
instruction="""You help the user turn an idea into a social media post.
Workflow:
1. When drafting, load and follow the relevant skills: post-formatter
(structure), platform-style (rules for the target platform), and
brand-voice (tone).
2. If the user asks for an image, load and follow the poster-style skill,
then call generate_image with a detailed visual description and tell
them where the file was saved.""",
tools=[generate_image, skill_toolset],
)
Restart and try: I want a LinkedIn post about why agent evals matter more than demos. Watch the Trace tab for load_skill calls: notice platform-style triggers a second-level read of references/linkedin.md, only because the draft is for LinkedIn. Ask for an X post instead and it reads references/x.md.
The Trace shows load_skill firing for post-formatter, platform-style and brand-voice, and a second-level load_skill_resource read of references/linkedin.md only when the draft is for LinkedIn. brand-voice loads with no name-mismatch error.
Add web research (and a real ADK constraint)
Add google_search, a built-in tool, so the agent can ground drafts in real facts. This step is designed to fail first. You'll hit a real Agent Platform constraint, read the error, and fix it the idiomatic ADK way.
Part 1, the naive version: add ADK's built-in google_search tool directly to root_agent's tools list, next to the existing tools. Run it and read the error. Part 2, the fix: move google_search into its own Agent named web_researcher that returns a concise factual summary, expose it to root_agent through AgentTool, and update the workflow instruction to research first when the idea needs facts.
Part 1: the naive version (this will fail, on purpose)
from google.adk.tools import google_search
root_agent = Agent(
# ...everything from Step 4 unchanged...
tools=[google_search, generate_image, skill_toolset],
)
Restart adk web and ask for a post that needs facts. Read the error before you touch anything.
Agent Platform rejects a single model call that mixes google_search with your own function tools: "Multiple tools are supported only when they are all search tools." The model is not broken and neither is your code; the platform enforces that built-in search runs alone. The fix: isolate search in its own agent, and expose that agent to the root agent as a tool via AgentTool. This is the idiomatic ADK pattern for "a tool that needs tools of its own", and it is your first multi-agent split.
Part 2: the fix
from google.adk.tools import google_search
from google.adk.tools.agent_tool import AgentTool
search_agent = Agent(
name="web_researcher",
model="gemini-2.5-flash",
description="Researches facts, dates, and context on the web.",
instruction="Research the given query and return a concise summary of relevant facts.",
tools=[google_search],
)
root_agent = Agent(
name="social_poster",
model="gemini-2.5-flash",
description="Helps the user turn an idea into a polished social media post.",
instruction="""You help the user turn an idea into a social media post.
Workflow:
1. If the idea needs facts, dates, or context, research it with the
web_researcher tool first.
2. When drafting, load and follow the relevant skills: post-formatter
(structure), platform-style (rules for the target platform), and
brand-voice (tone).
3. If the user asks for an image, load and follow the poster-style skill,
then call generate_image with a detailed visual description and tell
them where the file was saved.""",
tools=[AgentTool(agent=search_agent), generate_image, skill_toolset],
)
Run the full flow: idea β research β skills β draft β image, all in one conversation. This is a complete, standalone agent; everything below adds real publishing.
First run: the error above appears β that's the point. After the fix: the Trace shows web_researcher as a tool call on social_poster, with google_search inside it, and the draft uses facts from the research.
Publish for real: Buffer + the approval gate
So far every tool has been Python running in-process. MCP (Model Context Protocol) is a standard for connecting to tools that live in a separate process: could be someone else's server, any language, doesn't need to know anything about ADK. Buffer runs one at https://mcp.buffer.com/mcp β no server for you to write or copy, just a key and a URL.
Add an McpToolset connected to Buffer's remote MCP server at https://mcp.buffer.com/mcp over StreamableHTTP, sending a Bearer token read from the BUFFER_API_KEY environment variable, with require_confirmation=True. Add it to root_agent's tools. Add strict posting rules to the instruction: always show the finished draft and get explicit approval before any posting action, and for an immediate post ask Buffer to publish now rather than schedule.
1. Connect a Buffer account
- Create or log into a free Buffer account, and connect at least one channel (LinkedIn is fine to test with; Buffer authenticates to each platform on its own side).
- Get an API key: Buffer's Publish settings β developer/API section.
- Add it to
backend/social_poster/.env:BUFFER_API_KEY=...
Buffer's create_post must be text only. Buffer's servers can't fetch a locally-generated image file; confirmed live: "Invalid post: Image could not be read from its URL." If the user wants an image with a Buffer post, generate it for the local gallery only and say so; images only travel with LinkedIn's own tool (Step 7).
2. Wire the toolset in, with the approval gate
import os
from google.adk.tools.mcp_tool import McpToolset
from google.adk.tools.mcp_tool.mcp_session_manager import StreamableHTTPConnectionParams
BUFFER_API_KEY = os.environ.get("BUFFER_API_KEY", "")
buffer_toolset = McpToolset(
connection_params=StreamableHTTPConnectionParams(
url="https://mcp.buffer.com/mcp",
headers={"Authorization": f"Bearer {BUFFER_API_KEY}"},
),
# Framework-level approval gate: see below, not just an instruction.
require_confirmation=True,
)
Add it to root_agent's tools=[...] list, and update the instruction with a posting rule:
instruction="""... (steps 1-3 from before) ...
4. ALWAYS show the finished draft to the user and ask for their approval
before any posting action. For an immediate post, ask Buffer to publish
now rather than schedule.
Posting rules (strict):
- Only post AFTER the user has explicitly approved the exact draft shown to
them. "Yes", "post it" counts; enthusiasm about the idea does not.
- After posting, share the returned confirmation with the user.""",
tools=[AgentTool(agent=search_agent), generate_image, skill_toolset, buffer_toolset],
Why require_confirmation, not just an instruction
Right now the instruction text says "ask before posting", but that's a suggestion, not a guarantee. require_confirmation=True is code-level: ADK pauses any call into this toolset and forces an explicit ToolConfirmation before the call actually executes, no matter what the model decides on its own.
Restart and try: draft β Yes, post it. Nothing fires until you approve inside adk web's confirmation control; try rejecting once too, and confirm the post never goes out.
require_confirmation=True applies to all of Buffer's tools, so listing channels will ask for approval too. That is fine for today. The reference build splits Buffer into two toolsets with tool_filter, one read-only and one gated, so only create_post pauses; try that yourself once the golden path works.
Unlike LinkedIn (Step 7), Buffer has no DRY_RUN flag. A "scheduled" post is real and will publish later on whatever channel is connected: require_confirmation=True above is the actual safety net here, not a simulation.
Say Yes, post it. adk web shows a confirmation control before anything reaches Buffer. Reject once: nothing appears in Buffer. Approve once: Buffer's response comes back and the post is in your Buffer queue.
Bonus: add LinkedIn as a second publisher
Buffer covers scheduling and multi-platform posting. LinkedIn's own API adds one thing Buffer can't: an immediate post with an image attached. This step is entirely optional; skip to Step 8 if you're short on time.
Add a second McpToolset that launches mcp/linkedin_server.py over stdio using the current Python interpreter, passing DRY_RUN and LINKEDIN_ACCESS_TOKEN through from the environment, filtered to get_profile and create_post, with require_confirmation=True. Add a POST_VIA environment switch (buffer, linkedin, or blank for both) that decides which toolsets are attached, and add routing guidance to the instruction: LinkedIn for an immediate post with an image, Buffer for scheduling or other platforms.
1. Read the given LinkedIn MCP server
The starter repo already contains mcp/linkedin_server.py: a FastMCP server with two tools, get_profile and create_post. Open it and find the line where DRY_RUN short-circuits the real LinkedIn API call β that flag is what makes this step safe to run.
2. Get an access token
- Create a LinkedIn Developer app: linkedin.com/developers/apps β Create app (needs a company page; a dummy one is fine).
- Add products: Share on LinkedIn (grants
w_member_social) and Sign In with LinkedIn using OpenID Connect (grantsopenid profile), both instant self-serve. - Fastest path to a token: the OAuth Token Generator in the Developer Portal: select your app, check all scopes, Request access token, copy it straight in.
DRY_RUN=true
LINKEDIN_ACCESS_TOKEN=...
With DRY_RUN=true (the default), the server logs the payload and returns a fake URL; nothing touches LinkedIn. Only flip it for a single deliberate happy-path check, then flip it straight back.
3. Wire both toolsets in with routing
import sys
from google.adk.tools.mcp_tool.mcp_session_manager import StdioConnectionParams
from mcp import StdioServerParameters
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[2]
MCP_SERVER = PROJECT_ROOT / "mcp" / "linkedin_server.py"
linkedin_toolset = McpToolset(
connection_params=StdioConnectionParams(
server_params=StdioServerParameters(
command=sys.executable,
args=[str(MCP_SERVER)],
env={
"DRY_RUN": os.environ.get("DRY_RUN", "true"),
"LINKEDIN_ACCESS_TOKEN": os.environ.get("LINKEDIN_ACCESS_TOKEN", ""),
},
),
),
tool_filter=["get_profile", "create_post"],
require_confirmation=True,
)
# POST_VIA overrides the LLM's own routing: "buffer" drops the LinkedIn
# tool entirely, "linkedin" drops Buffer even if a key is set. Blank
# exposes both and lets the agent route by what the user asks for.
POST_VIA = os.environ.get("POST_VIA", "").strip().lower()
use_linkedin = POST_VIA != "buffer"
use_buffer = bool(BUFFER_API_KEY) and POST_VIA != "linkedin"
With both LinkedIn's tool and a real Buffer key active together, every turn fails: "Duplicate function declaration found: create_post"; the two tools share a name and Gemini rejects the collision. Leave POST_VIA blank only if BUFFER_API_KEY is also blank; otherwise set it to buffer or linkedin explicitly in .env.
Add the routing guidance to the instruction (choose LinkedIn for an image-carrying immediate post, Buffer for scheduling or other platforms), and add linkedin_toolset to tools=[...] alongside buffer_toolset. Both still gate through require_confirmation=True: same mechanism, two publishers.
Restart and try: ask specifically for an immediate LinkedIn post with an image, then ask to schedule something for next week; watch it pick a different tool each time.
With POST_VIA=linkedin, an immediate post with an image routes to LinkedIn's create_post and the DRY_RUN log shows the payload. With POST_VIA=buffer, the same request goes to Buffer. With both active and POST_VIA blank, you get the duplicate-declaration error described above.
Give it a face: wire up the frontend
CopilotKit + Next.js wiring is its own skill outside ADK, so the frontend is given to you, as-is, already in your starter repo. What matters today is the two connection points that make it talk to the agent you just built.
In agent.py, add the three pipeline-stage callbacks described in connection point 3. backend/main.py (connection point 1) already exists in the starter repo: read it, but you don't need to change it.
Nothing to copy: backend/main.py, frontend/, run-backend.sh and run-frontend.sh came with the starter repo. Run chmod +x run-backend.sh run-frontend.sh once if they are not executable.
Connection point 1: the AG-UI bridge (given: read this one)
This is short, and it's the entire reason Step 8 exists: exposing the exact same root_agent object over HTTP.
"""AG-UI bridge: exposes the social_poster agent to the CopilotKit frontend.
Run from the repo root: uv run uvicorn backend.main:app --port 8000
"""
import pathlib
from ag_ui_adk import ADKAgent, add_adk_fastapi_endpoint
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
load_dotenv(pathlib.Path(__file__).parent / "social_poster" / ".env")
from backend.social_poster import db # noqa: E402 (needs .env first)
from backend.social_poster.agent import root_agent # noqa: E402 (needs .env first)
app = FastAPI(title="social-agent AG-UI backend")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
adk_agent = ADKAgent(
adk_agent=root_agent,
app_name="social_poster",
user_id="devcamp-user", # single-user POC; extract from auth in real apps
)
add_adk_fastapi_endpoint(app, adk_agent, path="/api/adk")
# Serve generate_image's output files so the frontend's image gallery
# (PostGallery.tsx) can render them β tools.py writes local file paths, which
# the browser has no way to read directly otherwise.
GALLERY_DIR = pathlib.Path(__file__).resolve().parents[1] / "gallery"
GALLERY_DIR.mkdir(parents=True, exist_ok=True)
app.mount("/outputs", StaticFiles(directory=GALLERY_DIR), name="outputs")
@app.get("/healthz")
def healthz() -> dict:
return {"status": "ok"}
@app.get("/api/posts")
def posts() -> dict:
"""Published posts (with whatever image they used), read from the local
SQLite store β durable across reloads/restarts, unlike ADK session state
or the browser's own chat history."""
return {"posts": db.list_posts()}
That's the whole bridge, plus a health check and the /api/posts route the post gallery reads. root_agent is imported unchanged from the agent.py you spent the last six steps writing.
Connection point 2: two given files worth reading
Open these; don't retype them, just understand what they're doing, since they're what makes the approval gate and pipeline stepper actually visible in the browser.
useCopilotAction({
name: "adk_request_confirmation", // ADK synthesizes exactly this call name
renderAndWaitForResponse: ({ args, status, respond }) => {
// ...renders Approve / Reject buttons...
return (
<button onClick={() => respond?.({ confirmed: true })}>Approve</button>
);
},
});
Without this component registered, nothing renders the pending confirmation: a plain-text "yes, post it" reply never resolves it, and the run just hangs. The framework-level gate from Step 6 needs a UI half; this is it.
const { state } = useCoAgent<AgentState>({ name: "social_poster" });
const current = state?.pipeline_stage ?? "idea"; // shared state, not a message
Connection point 3: publish the pipeline stage (you write this one too)
That shared pipeline_stage value has to come from somewhere: three small callbacks on root_agent that write it into session state as the run progresses. Nothing else in the agent changes.
from typing import Any, Optional
from google.adk.agents.callback_context import CallbackContext
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.tool_context import ToolContext
# idea -> researching -> drafting -> awaiting_approval -> posted
_TOOL_STAGES = {
"web_researcher": "researching",
"load_skill": "drafting",
"generate_image": "drafting",
}
def _init_stage(callback_context: CallbackContext) -> None:
if "pipeline_stage" not in callback_context.state:
callback_context.state["pipeline_stage"] = "idea"
def _track_stage(
tool: BaseTool, args: dict[str, Any], tool_context: ToolContext, tool_response: dict
) -> Optional[dict]:
if tool.name in _TOOL_STAGES:
tool_context.state["pipeline_stage"] = _TOOL_STAGES[tool.name]
elif tool.name == "create_post" and tool_response.get("isError") is False:
tool_context.state["pipeline_stage"] = "posted" # only a completed MCP call
return None # never modify the tool result
def _stage_after_agent(callback_context: CallbackContext) -> None:
# A turn that ends mid-pipeline is waiting on the user (draft approval).
if callback_context.state.get("pipeline_stage") in ("researching", "drafting"):
callback_context.state["pipeline_stage"] = "awaiting_approval"
root_agent = Agent(
# ...name, model, description, instruction, tools as before...
before_agent_callback=_init_stage,
after_tool_callback=_track_stage,
after_agent_callback=_stage_after_agent,
)
The Consulting memory stage in the panel stays dim today; it lights up when a memory agent joins in the reference build.
Run it: two terminals
./run-backend.sh # uv sync + uvicorn on :8000
./run-frontend.sh # npm install + next dev on :3000
Open http://localhost:3000 and continue as guest.
The backend on :8000 answers /healthz, the frontend on :3000 loads, a message in the chat gets a reply from the same agent you tested in adk web, and the stage panel moves off Idea as soon as research starts.
Run the golden path
One full loop, in the browser, exercising everything you built today.
In the chat sidebar: Draft a LinkedIn post about why agent evals matter more than demos, with an illustration, and post it via Buffer.
What to verify
- The stage panel tracks the run live: Idea β Researching β Drafting β Approval β Posted. (Consulting memory stays dim; the image is generated inside Drafting.)
- The activity feed shows every tool call β
web_researcher,load_skill,generate_imageβ with expandable arguments and results. - A confirmation card appears before publishing, with Approve / Reject; try Reject once and confirm nothing posts.
- On Approve, Buffer's response (or LinkedIn's dry-run URL, if you built Step 7) appears in the chat.
Troubleshooting
| Symptom | Fix |
|---|---|
| Wrong GCP project / creds showing | gcloud config configurations activate adk-devcamp (use the name of your own gcloud configuration, or run gcloud config set project ${PROJECT_ID} with your actual project ID) |
| Port already in use | Something else is on 8000 or 3000; stop it, or run on a different port and update ADK_BACKEND_URL |
| Chat hangs after "yes, post it" | ConfirmAction.tsx missing or not imported into page.tsx (Step 8) |
Duplicate function declaration: create_post | Both toolsets active with POST_VIA blank; pin it to buffer or linkedin (Step 7) |
| Buffer post fails with an image error | Never attach image_path to a Buffer call; text only (Step 6) |
| Stage panel stuck on Idea | The three callbacks from Step 8 are not attached to root_agent, or a tool name in _TOOL_STAGES doesn\'t match yours |
What you built
- An
Agentwith an instruction and a hand-written function tool - Agent Skills β progressive disclosure via
SkillToolset+load_skill_from_dir, including one you wrote yourself AgentToolto isolategoogle_searchβ a real Agent Platform constraint, not an arbitrary patternMcpToolsetagainst Buffer's remote server, gated byrequire_confirmation=Trueβ a framework-level human-in-the-loop, independent of the model's own judgement- Optionally, a second publisher (LinkedIn) with request-level routing between the two
- The AG-UI bridge that exposes that exact agent to a real web app, plus the two frontend files that make ADK's confirmation gate and shared state visible
What's next: the other three pillars
This same agent keeps growing β one pillar at a time, each with its own lab:
| Scale | Deploy the agent to Agent Runtime (managed, sub-second cold starts) with a per-agent Agent Identity, instead of the hand-rolled Cloud Run + FastAPI bridge β Lab 2: Scale |
| Govern | Model Armor + Cloud DLP guardrails, plus real Governance Policies, Agent Gateway, and Agent Registry β the platform surface, not just app-code REST calls β Lab 3: Govern |
| Optimise | An eval-scored golden set, BigQuery Agent Analytics, and the Unified Trace Viewer β Lab 4: Optimise |
The pillar-build tag on the ai-devcamp-labs repo is published the day after the session: the finished version of everything above, and the exact starting point Lab 2 assumes. Use it to catch up before next Saturday, not as a substitute for the build.
Assignment
Before moving on to Lab 2, apply what you just built to your own agent idea β something you actually want to build, not this LinkedIn-poster. Working in your own GCP project:
- Write one
Agentwith a clear, single-purpose instruction and at least one hand-written function tool (not a canned example) - Give it one Agent Skill of your own β a
SKILL.mdthat encodes a rule your agent should always follow, loaded viaSkillToolset - Isolate any built-in tool (like
google_search) in its own sub-agent if you combine it with function tools, and connect it viaAgentTool - Wire in one MCP toolset β local (stdio) or remote (HTTP) β for a real side effect, gated with
require_confirmation=True
Done looks like: uv run adk web against your own agent, a full turn that calls your tool and your skill, and the confirmation gate actually blocking an unapproved side effect. No answer key β the point is applying the pattern, not matching this repo's code.
References
- Agent Development Kit docs β agent types, tools, sessions, the full API reference for everything in this lab.
- Gemini Enterprise Agent Platform overview β the four-pillar framework this whole lab series follows.
- Model Context Protocol (MCP) β the open spec behind today's LinkedIn/Buffer toolsets.