Ardian About Directory
Claude Code
Email me

agentkit

2026 — A Go library that turns Go clients into tools and memory for AI agents built on Google's Agent Development Kit

01 Problem

An AI agent is a language model given a written instruction and a list of tools. A tool is a function the program runs when the model asks for it by name, and the model reads what comes back and decides again until it can answer. An agent development framework is the code that runs this loop, and Google's Agent Development Kit, ADK for short, is one of them. It also keeps a session for each conversation, the messages so far, and hands it back to the model on every turn.

I already had Go clients for the services my agents use, spotify for music, tuya for the smart home, gworkspace for Gmail, Calendar, and Contacts, and postera for waking an agent later. They know nothing about agents on purpose, so an HTTP server, a scheduled job, or a command line tool can use them too. Before an agent can use one, every call has to become a tool, with a description the model reads to know when to call it and errors the model can act on.

My agents also keep their conversations in Zep, a service that stores conversations and learns facts about the person from them, and ADK does not come with a way to keep sessions there. Written inside each agent, all of that repeats in every agent that uses the same service, in the middle of code that is about what the agent is for.

02 Solution

go.trueardian.com/agentkit is an open source Go library that holds that code once, a library being code other programs import rather than a program that runs on its own. It is split into packages, one per service, so an agent imports only the ones it needs, and only those end up in its program.

It began on 6 April 2026 as go.naturallyfunny.dev/adk, one package that kept ADK sessions in Zep. The postera tools came on 30 May, and the spotify, tuya, and gworkspace tools came over the next five weeks, on 17 June, 20 June, and 1 July. On 5 October it moved to go.trueardian.com.

Install
go get go.trueardian.com/agentkit
Import only the packages you need
import (
spotifyadk "go.trueardian.com/agentkit/spotify/adk"
zepadk "go.trueardian.com/agentkit/zep/adk"
)

03 Agent development framework

Today ADK is the only framework agentkit supports, and only its Go version, google.golang.org/adk, the framework my agents are built on. ADK also comes in Python and Java, which agentkit does not cover.

Each package is laid out as integration and then framework, like spotify/adk, and that package, called a binding, is the only place that knows both the client and the framework. The clients know nothing about any framework, so supporting another one means adding a package next to each binding, named after that framework, without touching the clients or the ADK bindings. Every binding today is a package named adk, so each one is imported under its own name, like spotifyadk.

Until 24 July 2026 the packages sat side by side with no framework in their path, like go.naturallyfunny.dev/adk/spotify, and one of them, added in May, bound OpenAI's models. That day the OpenAI binding was removed, because choosing the model is the framework's job and ADK already ships its own, while this library is about tools, sessions, and memory. The library was renamed agentkit the same day, and every binding moved under its framework, as spotify/adk.

Layout
zep/adk        ──▶ zep-go     ─┐
spotify/adk ──▶ spotify │
tuya/adk ──▶ tuya ├─▶ adk
gworkspace/adk ──▶ gworkspace │
postera/adk ──▶ postera ─┘

04 Tools

Four bindings turn a client into tools, 24 in all. spotify/adk gives eleven, to search, read playlists, see what is playing and on which devices, and control playback. tuya/adk gives four, to check the linked account, list devices, read one, and send it commands. gworkspace/adk gives two each for Gmail, Calendar, and Contacts, and postera/adk gives three, to wake the agent's future self, list what is lined up, and cancel it. Each comes with its description already written.

Every tool acts for the person in the session. ADK hands each tool the user ID of the run, and the tool passes it to the client as the owner, so the agent runs under the same ID the client keeps that person's Spotify token or Tuya link under, and the model never sees a credential.

What the model reads is shaped by the binding, not by the client. Each binding has its own small JSON types, so a change inside a client cannot quietly change what the model sees. A binding handed no client returns an error at startup, next to the line that caused it, instead of crashing in the middle of a conversation.

A description changes when an agent gets something wrong. On 5 July 2026, after traced conversations showed Yori, the music agent, saying it could not play on an idle device or start a song inside a playlist, play gained context_uri, a new transfer_playback tool moves playback onto an idle device, and the description of play now tells the model to check my_devices and try again before asking the person to open Spotify.

Turn each client into tools
spotifyTools, err := spotifyadk.Tools(spotifyClient)
tuyaTools, err := tuyaadk.Tools(app)
gmailTools, err := gworkspaceadk.GmailTools(gmail)
calendarTools, err := gworkspaceadk.CalendarTools(calendar)
contactTools, err := gworkspaceadk.ContactTools(contacts)
posteraTools, err := posteraadk.Tools(postarius)
Give them to an agent
agent, err := llmagent.New(llmagent.Config{
Name: "yori",
Model: model,
Instruction: instruction,
Tools: spotifyTools,
})

05 Error handling

When a tool fails, ADK hands the error to the model as text, and the model decides what to do next from it. A name like spotify.ErrNoActiveDevice tells a model nothing about that. So each binding rewrites the errors its client names into what to do about them. No active device becomes a sentence telling the model to ask the person to open Spotify or to check my_devices, and an account that is not linked yet becomes a sentence the model can pass on.

Every other error reaches the model unchanged, so a real bug is never smoothed over into a friendly sentence that is not true.

Inside spotify/adk
func forAgent(err error) error {
switch {
case errors.Is(err, spotify.ErrNotConnected):
return errors.New("the human hasn't connected their Spotify account yet — they need to link it before I can do this")
case errors.Is(err, spotify.ErrNoActiveDevice):
return errors.New("no active Spotify device — ask the human to open Spotify somewhere, or check my_devices, then try again")
case errors.Is(err, spotify.ErrPremiumRequired):
return errors.New("this needs Spotify Premium; the human's account can't do playback control")
case errors.Is(err, spotify.ErrRateLimited):
return errors.New("Spotify is rate-limiting right now — wait a moment before trying again")
default:
return err
}
}

06 Session

A session service is the part of ADK that saves each turn of a conversation and loads the conversation back before the next one. agentkit's comes from zep/adk and keeps every ADK session as one Zep thread, with the session ID as the thread ID. Create adds the person to Zep if they are not there yet and then makes the thread, Get loads the last messages, as many as WithMessageHistoryLength says, and AppendEvent writes each turn back.

Only turns with text are written. A tool call, its result, and an empty model turn never reach Zep, yet they stay in the session in memory, because ADK's loop has to see them while the turn is still running. Since 5 July 2026 a turn made only of invisible characters, like a zero-width space a model sometimes answers with, counts as empty too, after Zep kept such turns as empty bubbles.

One conversation can hold one person and several agents, so every history message reaches the model with its speaker in front, like [ardian] play Cigarettes. An agent's message is saved under the agent's name. A person's message is saved under the session's user ID, unless a speaker resolver gives a real name, one fixed name with StaticSpeaker or one per request with SpeakerFromContext, set by middleware, the code that runs before every request. A request with no speaker makes SpeakerFromContext fail, so a forgotten middleware never saves a message under the wrong name.

Models copy what they see, and they started opening their own replies with the same bracket. So WithInstruction writes a block into the session state, values ADK puts into the agent's instruction wherever a placeholder names them, telling the model that the bracket is added by the system and that its reply has no leading bracket, no name, and no timestamp. An agent turns it on by adding {temp:session-instruction?} to its instruction, where the question mark keeps the turn alive when the block is empty.

A model has no clock either. It does not know what time it is, or that the last message was three days ago. WithTimeHarness adds the local date and time to the prefix, like [2026-11-10 15:58 ardian], and the current local time and how long it has been since the previous message to the instruction block. The zone is one fixed zone with StaticTZ or one per request with TZFromContext. A zone name that does not exist stops StaticTZ at startup, since that name sits in the source code, while a bad or missing zone from a request is data and comes back as an error from Get, and so does a history message without a readable time.

A session belongs to the person who started it. Until 13 June 2026, Get loaded a thread by its ID alone, so anyone who sent another person's session ID got back that whole conversation. Get now compares the owner Zep reports with the person asking and returns ErrSessionOwnerMismatch when they differ or when Zep reports no owner, which an HTTP server answers with 403, the status for forbidden. Create runs the same check, because with AutoCreateSession on, ADK's runner calls Create after any error from Get, the rejected one included. A request with no user ID is not checked, so the app's own internal tools can still reach every thread.

The figure below changes the time since the previous message, the zone, and the speaker, and shows what the model reads on plain ADK next to what it reads with agentkit.

Empty falls back to the session's user ID.

Plain ADK

Instruction
~ your instruction ~
History
Play Cigarettes by Juice WRLD.
Cigarettes is playing on the living room speaker.
New message
Play it again.

agentkit

Instruction
~ your instruction ~
[MESSAGES_HISTORY_FORMAT]
Incoming message history will be shown as: [YYYY-MM-DD HH:MM Name] {raw message content}
IMPORTANT AND CRITICAL:
- The bracketed prefix is metadata the system adds to identify the speaker and the message's local time, and is NOT part of the message and NOT something a speaker typed.
- I as an agent should never write a prefix ([YYYY-MM-DD HH:MM Name]) — no leading bracket, no name, no timestamp. My response as an agent should be raw message content only.
- Timestamps are already in local time, so I as an agent should never reason about timezones.
[/MESSAGES_HISTORY_FORMAT]

[CURRENT_TIME]
Current date and time: 2026-11-10 15:58
Time since previous message: 3 days 11 hours 5 minutes

You are time-aware: the time shown above is the authoritative current local time. Rely on it whenever you need to know or reason about the current date or time — you never need to look it up elsewhere or call a tool to obtain it. Within this session — the conversation history above and the current date and time shown here — all times are already in the user's local timezone, so you do not need to convert or reason about timezones for them.
[/CURRENT_TIME]
History
[2026-11-07 04:53 ardian] Play Cigarettes by Juice WRLD.
[2026-11-07 04:53 yori] Cigarettes is playing on the living room speaker.
New message
Play it again.
Create the session service
sess := zepadk.NewSessionService(zepClient,
zepadk.WithMessageHistoryLength(10),
zepadk.WithSpeakerResolver(zepadk.SpeakerFromContext()),
zepadk.WithInstruction("temp:session-instruction"),
zepadk.WithTimeHarness(zepadk.TZFromContext()),
)
Hand it to the runner
rnr, err := runner.New(runner.Config{
Agent: agent,
SessionService: sess,
AutoCreateSession: true,
})
Name the speaker and timezone on each request
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: name})
ctx = zepadk.WithTimezone(ctx, tz)
Add the placeholder to the instruction
~ your instruction ~
{temp:session-instruction?}

07 Memory

A session only holds one conversation. Zep also reads every thread and builds a knowledge graph of the person, the people, things, and preferences in their life joined by facts, and NewMemoryService in zep/adk gives ADK a memory service on top of it. SearchMemory searches that graph for the person, across all their threads, and hands back Zep's summary as one memory. With ADK's preloadmemorytool on the agent, that memory reaches the model on every request, so a new conversation already knows the person.

AddSessionToMemory does nothing, because every turn is already in a Zep thread and Zep builds the graph from the threads on its own.

Create the memory service
mem := zepadk.NewMemoryService(zepClient)
Hand it to the runner and the agent
agent, err := llmagent.New(llmagent.Config{
Name: "ava",
Model: model,
Instruction: instruction,
Tools: append(posteraTools, preloadmemorytool.New()),
})

rnr, err := runner.New(runner.Config{
Agent: agent,
SessionService: sess,
MemoryService: mem,
})

08 Limits

It is not a sign-in layer. Who the person is gets decided before a request reaches the agent, and the ownership check in zep/adk is only a backstop.

It cannot list or delete Zep threads. List and Delete exist only because an ADK session service must have them, and they do nothing, so threads are managed in Zep.

09 Use cases

Avagenc Chat, a group chat where one person talks with several AI agents, keeps every conversation in one Zep thread through zep/adk, with the last 16 messages as history and the time harness on. Each request names its speaker and the timezone of the person. The person writes as human, Ava writes as ava when she hands a request to a specialist, and Postera writes as postera, with the system role, when it wakes Ava up, so every agent reads the thread back knowing who said what and when.

Each Avagenc agent takes its tools from one binding. Ava, the lead, wakes herself later through postera/adk, Zee runs the smart home through tuya/adk, Yori plays music through spotify/adk, and Rafal handles Gmail, Calendar, and Contacts through gworkspace/adk.

The person writes
ctx = zepadk.WithTimezone(ctx, tz)
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: "human"})
Ava hands a request to a specialist
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: "ava"})
Postera wakes Ava up
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: "postera", Role: zep.RoleTypeSystemRole})

agentkit

Any Go agent on ADK gets Spotify, Tuya, Google Workspace, and Postera as tools and Zep as its memory, with one import each and errors written for the model