← All docs
Overview

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.

Theory β€” this is Google's "Build" pillar

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.

iterate 1 Define the goal & scope Lab 1 Β· Overview 2 Design the agent & interactions Lab 1 Β· Steps 2, 4 3 Build agents & tools Lab 1 Β· Steps 3–7 4 Configure & connect Lab 1 Β· Steps 6–8 5 Local run & debug Lab 1 Β· Steps 2, 9 6 Evaluate & iterate Lab 4 7 Deploy Lab 2 Build Β· Lab 1 (this lab) Scale Β· Lab 2 Optimise Β· Lab 4
The ADK development lifecycle, and where this camp covers each stage. Lab 1 covers the first five.
Agent Development Kit: a client-side SDK to define multi-agent applications THE COMPONENTS, AND WHICH LAB USES EACH Agents Steps 2, 5 Tool Steps 3, 5: your own functions Orchestration Step 5: agent as a tool Callbacks Step 8; guardrails in Lab 3 Bidirectional Streaming not used Session Management Lab 2 Evaluation Lab 4 Deployment Lab 2 Artifact Management not used Memory Lab 2: Memory Bank Code Execution not used Planning not used Agent Skills Step 4: load_skill on demand MCP Toolsets Steps 6–7: Buffer, LinkedIn Tool Confirmation Step 6: human approval gate Web UI (AG-UI) Step 8: CopilotKit frontend ALSO IN THIS CAMP: ADK FEATURES THE OFFICIAL PICTURE LEAVES OUT Debugging adk web Β· Steps 2–9 Trace the adk web Trace tab from Step 3; the full story in Lab 4 Models Gemini on Agent Platform Β· Steps 1–2 Build Β· Lab 1 (this lab) Scale Β· Lab 2 Optimise Β· Lab 4 Not used in this camp
ADK's components, plus the features this camp adds. Lab 1 is the blue part; the other labs cover the rest.
How this lab works

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.

πŸ’‘ You idea social_poster orchestrator agent Skills loaded on demand generate_image Gemini image tool web_researcher isolated sub-agent draft shown approval gate approved Buffer MCP remote Β· primary step 7, optional LinkedIn MCP stdio Β· bonus πŸš€ posted Everything left of the gate you build Steps 1–5. The gate + Buffer is Step 6. LinkedIn is optional Step 7.
The full system you're building today: one orchestrating agent, its tools, and two ways out to a real publish.

What's given vs. what you build

File / pieceWho 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.mdyou write Step 4
backend/social_poster/agent.pyyou 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.pygiven 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, and google-adk installed.
  • 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).
Running this in a 90-minute live session?

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.

If you fall behind

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.

Step 1 of 9⏱ 5 min

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.

Starter, not solution

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.

terminal
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

ai-devcamp-labs/ β”œβ”€β”€ AGENTS.md (given β€” read by your coding agent) β”œβ”€β”€ pyproject.toml β”œβ”€β”€ .env.example β”œβ”€β”€ backend/ β”‚ β”œβ”€β”€ main.py (given, you read it in Step 8) β”‚ └── social_poster/ β”‚ β”œβ”€β”€ __init__.py β”‚ β”œβ”€β”€ agent.py ← empty, you build this all workshop β”‚ β”œβ”€β”€ tools.py (given) β”‚ └── .env β”œβ”€β”€ skills/ β”‚ β”œβ”€β”€ post-formatter/ (given) β”‚ β”œβ”€β”€ platform-style/ (given) β”‚ └── poster-style/ (given) ← brand-voice is yours, Step 4 β”œβ”€β”€ mcp/linkedin_server.py (given, Step 7 only) └── frontend/ (given, as-is)
Checkpoint

uv sync completes with no errors, and uv run python -c "import google.adk" prints nothing (silence = success).

Step 2 of 9⏱ 5 min

The smallest possible agent

Everything in ADK starts with one object: an Agent, a model, an instruction, and optionally tools.

Ask your coding agent

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.

backend/social_poster/agent.py
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.",
)
terminal
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.

Checkpoint

The agent replies in the ADK web chat. Leave adk web running; you'll re-test after every step.

Step 3 of 9⏱ 10 min

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.

Ask your coding agent

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:

backend/social_poster/agent.py
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/.

Checkpoint

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.

Step 4 of 9⏱ 15 min

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.

Why skills matter
  • 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.
1 Β· ALWAYS IN CONTEXT 2 Β· LOADED WHEN RELEVANT the load_skill tool 3 Β· ONLY IF NEEDED the load_skill_resource tool post-formatter structure: hook, limits, hashtags SKILL.md hook line Β· short paragraphs Β· hashtags platform-style rules for the target platform SKILL.md which platform? then which reference file brand-voice tone and banned clichΓ©s SKILL.md casual Β· British English Β· no β€œgame-changer” poster-style visual style for images SKILL.md style rules for the generate_image prompt only loaded when the user asks for an image references/linkedin.md loaded: the draft is for LinkedIn references/x.md not loaded: not an X post A name and a one-line description. About a line each. The full instructions, for only the skills this draft needs. The agent decides, from the description. Deeper detail, for only the platform in question. Two levels down, still on demand.
How the agent uses skills in this lab: a cheap menu, then the recipe for what applies, then the fine print only where it matters. Ask for a LinkedIn post and watch the Trace: x.md never loads.
Ask your coding agent

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 for generate_image prompts (you'll wire this in properly once images route through the skill)

Now write one yourself: brand-voice

Create skills/brand-voice/SKILL.md:

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".
Gotcha

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

backend/social_poster/agent.py
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.

Checkpoint

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.

Step 5 of 9⏱ 10 min

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.

Ask your coding agent

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)

backend/social_poster/agent.py (attempt 1)
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.

What you'll see, and why

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

backend/social_poster/agent.py
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.

Checkpoint

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.

Step 6 of 9⏱ 20 min

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.

Ask your coding agent

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

  1. 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).
  2. Get an API key: Buffer's Publish settings β†’ developer/API section.
  3. Add it to backend/social_poster/.env: BUFFER_API_KEY=...
Gotcha: text only

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

backend/social_poster/agent.py
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:

backend/social_poster/agent.py (instruction excerpt)
    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.

Instruction only model create_post A misreading model can call it directly; nothing in the runtime stops it. require_confirmation=True model ADK gate renders in UI Approve / Reject create_post Only fires after a human explicitly responds. Reject and it never runs.
The gap between "the instructions say to ask first" and a framework-level pause the model cannot talk its way around.

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.

The gate covers every tool in the toolset

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.

Real posts, no dry-run

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.

Checkpoint

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.

Step 7 of 9 · Optional⏱ 15 min

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.

Ask your coding agent

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

  1. Create a LinkedIn Developer app: linkedin.com/developers/apps β†’ Create app (needs a company page; a dummy one is fine).
  2. Add products: Share on LinkedIn (grants w_member_social) and Sign In with LinkedIn using OpenID Connect (grants openid profile), both instant self-serve.
  3. 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.
backend/social_poster/.env
DRY_RUN=true
LINKEDIN_ACCESS_TOKEN=...
Keep DRY_RUN=true

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

backend/social_poster/agent.py
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"
Known bug: pin POST_VIA explicitly

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.

Checkpoint

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.

Step 8 of 9⏱ 15 min

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.

Ask your coding agent

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.

backend/main.py
"""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.

Browser CopilotSidebar :3000 route.ts CopilotRuntime + HttpAgent HTTP main.py :8000 /api/adk (ADKAgent) you wrote this root_agent ConfirmAction.tsx renders adk_request_confirmation StagePanel.tsx reads pipeline_stage state
The frontend is given, but these two components (yellow) are the ones tapping ADK's own event stream, which is why they're worth opening.

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.

frontend/app/components/ConfirmAction.tsx (excerpt)
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>
    );
  },
});
Gotcha

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.

frontend/app/components/StagePanel.tsx (excerpt)
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.

backend/social_poster/agent.py (add above root_agent)
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"
backend/social_poster/agent.py (root_agent, three new arguments)
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

terminal 1
./run-backend.sh    # uv sync + uvicorn on :8000
terminal 2
./run-frontend.sh   # npm install + next dev on :3000

Open http://localhost:3000 and continue as guest.

Checkpoint

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.

Step 9 of 9⏱ 10 min

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.

βœ“
Idea
🧠
Consulting memory
βœ“
Researching
βœ“
Drafting
⏳
Approval
πŸš€
Posted

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

SymptomFix
Wrong GCP project / creds showinggcloud 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 useSomething 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_postBoth toolsets active with POST_VIA blank; pin it to buffer or linkedin (Step 7)
Buffer post fails with an image errorNever attach image_path to a Buffer call; text only (Step 6)
Stage panel stuck on IdeaThe three callbacks from Step 8 are not attached to root_agent, or a tool name in _TOOL_STAGES doesn\'t match yours
Wrap-up

What you built

  • An Agent with an instruction and a hand-written function tool
  • Agent Skills β€” progressive disclosure via SkillToolset + load_skill_from_dir, including one you wrote yourself
  • AgentTool to isolate google_search β€” a real Agent Platform constraint, not an arbitrary pattern
  • McpToolset against Buffer's remote server, gated by require_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:

ScaleDeploy 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
GovernModel 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
OptimiseAn eval-scored golden set, BigQuery Agent Analytics, and the Unified Trace Viewer β€” Lab 4: Optimise
If you got stuck anywhere today

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 Agent with 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.md that encodes a rule your agent should always follow, loaded via SkillToolset
  • Isolate any built-in tool (like google_search) in its own sub-agent if you combine it with function tools, and connect it via AgentTool
  • 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