agentkit
2026 — A Go library that plugs external clients into AI agent frameworks
Background
An agent is only as useful as what it can reach. I already had Go clients for the services I use, Spotify, Tuya, Google Workspace and Postera, and they don't know any agent framework exists. That's on purpose, so they stay usable from an HTTP handler, a cron job or a CLI.
agentkit is the layer in between. Each package is laid out as <integration>/<framework>, and that binding is the only place that
knows both sides. The only framework is Google's Agent Development Kit, the one I build on, so
every leaf is adk, and Zep comes in for the sessions and the memory.
Wiring
Only import what you need. Only the bindings you import end up in your binary, and every package
is named adk, so alias them on import.
Each binding is two imports, your client and its binding, and a call. Pick one and see the Go you would otherwise write yourself, all of it.
go get go.trueardian.com/agentkit Without agentkit764 lines
You have to manually implement ADK's session and memory services on top of Zep. Then you map every session to a Zep thread, put the speaker and the local time in front of every history message, write the instruction block that explains them to the model, check who owns each thread, and search the human's knowledge graph for memories.
zep/adk/memory.go, 64 lines
package adk
import (
"context"
"fmt"
"github.com/getzep/zep-go/v3"
"github.com/getzep/zep-go/v3/client"
"github.com/getzep/zep-go/v3/option"
"google.golang.org/adk/memory"
adksession "google.golang.org/adk/session"
"google.golang.org/genai"
)
type graphClient interface {
Search(ctx context.Context, request *zep.GraphSearchQuery, opts ...option.RequestOption) (*zep.GraphSearchResults, error)
}
// MemoryService implements memory.Service over a user-scoped Zep knowledge
// graph. It is independent of any session: SearchMemory is keyed by UserID, not
// by thread.
type MemoryService struct {
graphClient graphClient
}
var _ memory.Service = (*MemoryService)(nil)
func NewMemoryService(c *client.Client) *MemoryService {
return &MemoryService{graphClient: c.Graph}
}
// SearchMemory runs a user-scoped graph search and returns Zep's materialized
// context block as a single memory entry. The response is empty when nothing
// matches.
func (s *MemoryService) SearchMemory(ctx context.Context, req *memory.SearchRequest) (*memory.SearchResponse, error) {
scope := zep.GraphSearchScopeAuto
res, err := s.graphClient.Search(ctx, &zep.GraphSearchQuery{
Query: req.Query,
UserID: &req.UserID,
Scope: &scope,
})
if err != nil {
return nil, fmt.Errorf("zep: search graph of user %q: %w", req.UserID, err)
}
text := res.GetContext()
if text == nil || *text == "" {
return &memory.SearchResponse{}, nil
}
return &memory.SearchResponse{
Memories: []memory.Entry{{
Content: genai.NewContentFromText(*text, genai.RoleModel),
}},
}, nil
}
// AddSessionToMemory is a no-op: SessionService.AppendEvent already writes every
// turn to the Zep thread, which Zep ingests into the user graph automatically.
// It exists only to satisfy memory.Service.
func (s *MemoryService) AddSessionToMemory(ctx context.Context, sess adksession.Session) error {
return nil
}zep/adk/session.go, 700 lines
package adk
import (
"context"
"errors"
"fmt"
"iter"
"strings"
"sync"
"time"
"unicode"
"github.com/getzep/zep-go/v3"
"github.com/getzep/zep-go/v3/client"
"github.com/getzep/zep-go/v3/option"
threadclient "github.com/getzep/zep-go/v3/thread/client"
userclient "github.com/getzep/zep-go/v3/user"
"google.golang.org/adk/model"
adksession "google.golang.org/adk/session"
"google.golang.org/genai"
)
// TZResolver resolves the user's timezone for the time harness.
// Use StaticTZ or TZFromContext to create one.
type TZResolver struct {
resolve func(context.Context) (*time.Location, error)
}
type timeHarnessConfig struct {
zoneResolver *TZResolver
}
// Speaker identifies the actor behind an inbound user-role turn: a display name
// and the Zep role under which the message is persisted. An empty Role means
// the ADK-derived role (user) is kept unchanged.
type Speaker struct {
Name string
Role zep.RoleType
}
// SpeakerResolver resolves the Speaker attributed to inbound user-role turns.
// Use StaticSpeaker or SpeakerFromContext to create one.
type SpeakerResolver struct {
resolve func(context.Context) (Speaker, error)
}
type userClient interface {
Get(ctx context.Context, userID string, opts ...option.RequestOption) (*zep.User, error)
Add(ctx context.Context, request *zep.CreateUserRequest, opts ...option.RequestOption) (*zep.User, error)
}
var _ userClient = (*userclient.Client)(nil)
type threadClient interface {
Create(ctx context.Context, request *zep.CreateThreadRequest, opts ...option.RequestOption) (*zep.Thread, error)
AddMessages(ctx context.Context, threadID string, request *zep.AddThreadMessagesRequest, opts ...option.RequestOption) (*zep.AddThreadMessagesResponse, error)
Get(ctx context.Context, threadID string, request *zep.ThreadGetRequest, opts ...option.RequestOption) (*zep.MessageListResponse, error)
}
var _ threadClient = (*threadclient.Client)(nil)
type SessionService struct {
threadClient threadClient
userClient userClient
speakerResolver *SpeakerResolver
msgHistoryLength int
instructionKey string
timeHarness *timeHarnessConfig
}
var _ adksession.Service = (*SessionService)(nil)
type Option func(*SessionService)
func NewSessionService(c *client.Client, opts ...Option) *SessionService {
s := &SessionService{
threadClient: c.Thread,
userClient: c.User,
}
for _, opt := range opts {
opt(s)
}
return s
}
func WithMessageHistoryLength(n int) Option {
return func(s *SessionService) {
s.msgHistoryLength = n
}
}
// StaticSpeaker returns a speaker resolver backed by a fixed Speaker value.
// An empty Name means inbound user turns fall back to the session's UserID.
// An empty Role keeps the ADK-derived role unchanged.
func StaticSpeaker(sp Speaker) *SpeakerResolver {
return &SpeakerResolver{
resolve: func(context.Context) (Speaker, error) {
return sp, nil
},
}
}
type speakerContextKey struct{}
// WithSpeaker returns a child context carrying the Speaker for SpeakerFromContext.
func WithSpeaker(ctx context.Context, sp Speaker) context.Context {
return context.WithValue(ctx, speakerContextKey{}, sp)
}
// SpeakerFromContext returns a speaker resolver that reads the inbound user-turn
// Speaker from the request context. Use WithSpeaker to provide the Speaker per
// request. AppendEvent returns an error when the Speaker is absent or has an empty
// Name, so a forgotten context does not silently mislabel the turn.
func SpeakerFromContext() *SpeakerResolver {
return &SpeakerResolver{
resolve: func(ctx context.Context) (Speaker, error) {
sp, ok := ctx.Value(speakerContextKey{}).(Speaker)
if !ok || sp.Name == "" {
return Speaker{}, errors.New("zep: SpeakerFromContext active but speaker absent or name empty")
}
return sp, nil
},
}
}
// WithSpeakerResolver sets how inbound user-role turns are attributed in the Zep
// thread, using the provided speaker source. Without this option, user turns are
// attributed to the session's UserID.
func WithSpeakerResolver(r *SpeakerResolver) Option {
if r == nil || r.resolve == nil {
panic("zep: WithSpeakerResolver: resolver must be created by StaticSpeaker or SpeakerFromContext")
}
return func(s *SessionService) {
s.speakerResolver = r
}
}
// WithInstruction registers the state key under which a combined session
// instruction block is written during Get. The block includes a message format
// section whenever history is present, and a current-time section when the time
// harness is enabled. The consumer includes {key?} (or {key}) in their agent
// instruction for the placeholder to be resolved by the ADK runner. When not
// set, no instruction is written to state.
func WithInstruction(key string) Option {
return func(s *SessionService) {
s.instructionKey = key
}
}
// StaticTZ returns a zone backed by a fixed IANA timezone.
// Empty string means UTC. Invalid timezone names panic because they are
// programmer configuration errors.
func StaticTZ(tz string) *TZResolver {
loc, err := time.LoadLocation(tz)
if err != nil {
panic(fmt.Sprintf("zep: StaticTZ: %v", err))
}
return &TZResolver{
resolve: func(context.Context) (*time.Location, error) {
return loc, nil
},
}
}
type timezoneContextKey struct{}
// WithTimezone returns a child context carrying an IANA timezone string
// for TZFromContext.
func WithTimezone(ctx context.Context, tz string) context.Context {
return context.WithValue(ctx, timezoneContextKey{}, tz)
}
// TZFromContext configures the time harness to resolve the timezone from the
// request context. Use WithTimezone to provide the timezone per request.
func TZFromContext() *TZResolver {
return &TZResolver{
resolve: func(ctx context.Context) (*time.Location, error) {
tz, _ := ctx.Value(timezoneContextKey{}).(string)
if tz == "" {
return nil, errors.New("zep: TZFromContext active but timezone absent or empty")
}
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, fmt.Errorf("zep: TZFromContext: %w", err)
}
return loc, nil
},
}
}
// WithTimeHarness enables time-awareness using the provided zone source.
// A nil zone enables time-awareness without timezone conversion; timestamps are
// formatted in their parsed timezone, and the current-time anchor uses UTC.
//
// When enabled:
// - History message prefix becomes [YYYY-MM-DD HH:MM Name] instead of [Name]
// - Any message with unparseable CreatedAt causes Get to return an error
func WithTimeHarness(zoneResolver *TZResolver) Option {
if zoneResolver != nil && zoneResolver.resolve == nil {
panic("zep: WithTimeHarness: zone must be created by StaticTZ or TZFromContext")
}
return func(s *SessionService) {
s.timeHarness = &timeHarnessConfig{zoneResolver: zoneResolver}
}
}
func (s *SessionService) ensureUser(ctx context.Context, userID string) error {
_, err := s.userClient.Get(ctx, userID)
if err == nil {
return nil
}
var notFound *zep.NotFoundError
if !errors.As(err, ¬Found) {
return err
}
_, err = s.userClient.Add(ctx, &zep.CreateUserRequest{UserID: userID})
return err
}
// ErrSessionOwnerMismatch is returned by Get when the requesting user is not
// the owner of the requested thread. Callers (e.g. an HTTP handler) should map
// it to 403 Forbidden.
var ErrSessionOwnerMismatch = errors.New("zep: session does not belong to user")
func derefOrEmpty(s *string) string {
if s == nil {
return ""
}
return *s
}
// verifyThreadOwner enforces that an existing thread belongs to the requesting
// user. It runs only when expectedUserID is non-empty (an authenticated
// request carries a user identity); requests without an identity — internal
// callers, tests — are not gated.
//
// It is fail-closed: when a user identity is present but Zep does not report a
// thread owner, ownership cannot be confirmed and access is denied. This is the
// security path, so an unconfirmable owner is treated as a mismatch rather than
// waved through.
func verifyThreadOwner(resp *zep.MessageListResponse, expectedUserID string) error {
if expectedUserID == "" {
return nil
}
if derefOrEmpty(resp.GetUserID()) != expectedUserID {
return ErrSessionOwnerMismatch
}
return nil
}
type state struct {
mu sync.RWMutex
m map[string]any
}
var _ adksession.State = (*state)(nil)
func (s *state) Get(key string) (any, error) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
if !ok {
return nil, adksession.ErrStateKeyNotExist
}
return v, nil
}
func (s *state) Set(key string, val any) error {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
return nil
}
func (s *state) All() iter.Seq2[string, any] {
s.mu.RLock()
cp := make(map[string]any, len(s.m))
for k, v := range s.m {
cp[k] = v
}
s.mu.RUnlock()
return func(yield func(string, any) bool) {
for k, v := range cp {
if !yield(k, v) {
return
}
}
}
}
type events []*adksession.Event
func (e events) All() iter.Seq[*adksession.Event] {
return func(yield func(*adksession.Event) bool) {
for _, evt := range e {
if !yield(evt) {
return
}
}
}
}
func (e events) Len() int { return len(e) }
func (e events) At(i int) *adksession.Event {
if i < 0 || i >= len(e) {
return nil
}
return e[i]
}
type session struct {
id string
userID string
app string
events []*adksession.Event
state *state
lastUpdate time.Time // zero if no timestamped messages were fetched
}
func (s *session) ID() string { return s.id }
func (s *session) AppName() string { return s.app }
func (s *session) UserID() string { return s.userID }
// LastUpdateTime returns the timestamp of the most recent message fetched from
// Zep. Returns zero time.Time{} when no messages carry a parseable CreatedAt
// (e.g. a brand-new or empty thread).
func (s *session) LastUpdateTime() time.Time { return s.lastUpdate }
func (s *session) State() adksession.State { return s.state }
func (s *session) Events() adksession.Events { return events(s.events) }
func (s *SessionService) timeHarnessEnabled() bool {
return s.timeHarness != nil
}
// resolveLocation returns the timezone for header formatting. A nil location
// means timestamps should be formatted in their parsed timezone.
func (s *SessionService) resolveLocation(ctx context.Context) (*time.Location, error) {
if !s.timeHarnessEnabled() || s.timeHarness.zoneResolver == nil {
return nil, nil
}
return s.timeHarness.zoneResolver.resolve(ctx)
}
func (s *SessionService) buildMessageFormatInstruction() string {
if s.timeHarnessEnabled() {
return `[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]`
}
return `[MESSAGES_HISTORY_FORMAT]
Each history message is shown as: [Name] raw message content
IMPORTANT AND CRITICAL:
- The bracketed prefix is metadata the system adds to identify the speaker. It is NOT part of the message and NOT something a speaker typed.
IMPORTANT:
- I as an agent should never write a prefix ([Name]) — no leading bracket, no name, no timestamp. My response as an agent should be raw message content only.
[/MESSAGES_HISTORY_FORMAT]`
}
const timeAwarenessFraming = "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."
// formatElapsed returns a human-readable duration.
// Pluralization is always-plural by design (e.g. "1 minutes") — LLMs
// tolerate this and avoiding plural/singular branching keeps the
// function simple.
func formatElapsed(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%d seconds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%d minutes", int(d.Minutes()))
}
if d < 24*time.Hour {
h := int(d.Hours())
m := int(d.Minutes()) - h*60
return fmt.Sprintf("%d hours %d minutes", h, m)
}
totalMinutes := int(d.Minutes())
days := totalMinutes / (24 * 60)
remaining := totalMinutes - days*24*60
h := remaining / 60
m := remaining - h*60
return fmt.Sprintf("%d days %d hours %d minutes", days, h, m)
}
func buildTimeAwarenessInstruction(loc *time.Location, lastTime time.Time) string {
now := time.Now().UTC()
if loc != nil {
now = now.In(loc)
}
nowStr := now.Format("2006-01-02 15:04")
if lastTime.IsZero() {
return fmt.Sprintf("[CURRENT_TIME]\nCurrent date and time: %s\n\n%s\n[/CURRENT_TIME]", nowStr, timeAwarenessFraming)
}
elapsed := time.Since(lastTime)
return fmt.Sprintf(
"[CURRENT_TIME]\nCurrent date and time: %s\nTime since previous message: %s\n\n%s\n[/CURRENT_TIME]",
nowStr, formatElapsed(elapsed), timeAwarenessFraming,
)
}
// applyInstruction writes the combined session instruction into st under the
// configured key. It is the single point both session entry points funnel
// through — Get (the fetched path) and Create (the auto-create path) — so the
// instruction placeholder resolves identically regardless of which produced the
// session. No-op when no key is registered or there is nothing to inject.
func (s *SessionService) applyInstruction(st *state, hasHistory bool, loc *time.Location, lastTime time.Time) {
if s.instructionKey == "" {
return
}
var parts []string
if hasHistory {
parts = append(parts, s.buildMessageFormatInstruction())
}
if s.timeHarnessEnabled() {
parts = append(parts, buildTimeAwarenessInstruction(loc, lastTime))
}
if len(parts) == 0 {
return
}
_ = st.Set(s.instructionKey, strings.Join(parts, "\n\n"))
}
func (s *SessionService) Create(ctx context.Context, req *adksession.CreateRequest) (*adksession.CreateResponse, error) {
loc, err := s.resolveLocation(ctx)
if err != nil {
return nil, err
}
if err := s.ensureUser(ctx, req.UserID); err != nil {
return nil, fmt.Errorf("zep: ensure user %q: %w", req.UserID, err)
}
// Ownership guard. The ADK runner calls Create on ANY error from Get —
// including ErrSessionOwnerMismatch — when AutoCreateSession is enabled, so a
// cross-user request that Get rejected would otherwise fall through to
// thread.Create against someone else's thread, whose server-side outcome
// (conflict, idempotent success, or owner rebind) is undefined. Decide the
// outcome here rather than depend on Zep: an existing thread must belong to
// the requester; a brand-new thread (NotFound) proceeds normally.
existing, err := s.threadClient.Get(ctx, req.SessionID, &zep.ThreadGetRequest{Lastn: zep.Int(1)})
var notFound *zep.NotFoundError
switch {
case err == nil:
if err := verifyThreadOwner(existing, req.UserID); err != nil {
return nil, err
}
case errors.As(err, ¬Found):
// Thread does not exist yet — safe to create below.
default:
return nil, fmt.Errorf("zep: get thread %q: %w", req.SessionID, err)
}
if _, err := s.threadClient.Create(ctx, &zep.CreateThreadRequest{
ThreadID: req.SessionID,
UserID: req.UserID,
}); err != nil {
return nil, fmt.Errorf("zep: create thread %q: %w", req.SessionID, err)
}
sess := &session{
id: req.SessionID,
userID: req.UserID,
app: req.AppName,
state: &state{m: make(map[string]any)},
}
// Seed the session instruction on the create path too, not just Get. The ADK
// runner calls Create (with AutoCreateSession) for the first turn on a brand-
// new thread; the session it hands back must carry the same instruction state
// a fetched session would, or a non-optional {instructionKey} placeholder in
// the agent instruction fails to resolve and the whole first turn errors out.
// A brand-new thread has no history and no prior timestamp, so build with
// hasHistory=false and a zero lastTime — the same values Get uses for an
// empty thread.
s.applyInstruction(sess.state, false, loc, time.Time{})
return &adksession.CreateResponse{Session: sess}, nil
}
// isBlank reports whether s carries no persistable content — i.e. it renders
// visually empty. Beyond ASCII/Unicode whitespace it also ignores zero-width
// and bidirectional formatting runes (Unicode category Cf: e.g. U+200B
// ZERO WIDTH SPACE, U+200E LEFT-TO-RIGHT MARK, U+FEFF) and control runes. A
// model that is asked for — or that reflexively emits — an "empty" turn may
// return one of these invisible characters rather than a truly empty string;
// unicode.IsSpace alone would let it through and pollute the thread with a
// blank message. The invariant stays content-driven: no visible content, no
// Zep message.
func isBlank(s string) bool {
for _, r := range s {
switch {
case unicode.IsSpace(r), unicode.IsControl(r), unicode.Is(unicode.Cf, r):
continue
default:
return false
}
}
return true
}
func (s *SessionService) AppendEvent(ctx context.Context, sess adksession.Session, event *adksession.Event) error {
if event == nil {
return nil
}
// Always update in-memory state: the ADK flow loop reads sess.Events() on
// every iteration, so function-call and function-response events must be
// visible even though they carry no text and are not persisted to Zep.
if impl, ok := sess.(*session); ok {
impl.events = append(impl.events, event)
for k, v := range event.Actions.StateDelta {
_ = impl.state.Set(k, v)
}
}
var contentStr string
if event.Content != nil {
for _, part := range event.Content.Parts {
contentStr += part.Text
}
}
if isBlank(contentStr) {
return nil
}
var zepRole zep.RoleType
var speakerName string
switch event.Author {
case "user", "human":
zepRole = zep.RoleTypeUserRole
speakerName = sess.UserID()
if s.speakerResolver != nil {
sp, err := s.speakerResolver.resolve(ctx)
if err != nil {
return err
}
if sp.Name != "" {
speakerName = sp.Name
}
if sp.Role != "" {
zepRole = sp.Role
}
}
case "system":
zepRole = zep.RoleTypeSystemRole
default:
zepRole = zep.RoleTypeAssistantRole
speakerName = event.Author
}
msg := &zep.Message{
Role: zepRole,
Content: contentStr,
}
if speakerName != "" {
msg.Name = &speakerName
}
if _, err := s.threadClient.AddMessages(ctx, sess.ID(), &zep.AddThreadMessagesRequest{
Messages: []*zep.Message{msg},
}); err != nil {
return fmt.Errorf("zep: add message to thread %q: %w", sess.ID(), err)
}
return nil
}
func (s *SessionService) fetchHistory(ctx context.Context, sessionID, expectedUserID string, loc *time.Location) ([]*adksession.Event, time.Time, error) {
lastn := s.msgHistoryLength
if lastn == 0 {
lastn = 1 // minimum fetch to verify the thread exists in Zep
}
resp, err := s.threadClient.Get(ctx, sessionID, &zep.ThreadGetRequest{Lastn: zep.Int(lastn)})
if err != nil {
return nil, time.Time{}, fmt.Errorf("zep: get thread %q: %w", sessionID, err)
}
// Ownership guard: an existing thread must belong to the requesting user.
// A nonexistent thread returns NotFound above (before this point), so the
// AutoCreateSession path — which binds the thread to req.UserID — is never
// blocked here. The guard runs before the msgHistoryLength == 0 early
// return so the verify-only path is protected too.
if err := verifyThreadOwner(resp, expectedUserID); err != nil {
return nil, time.Time{}, err
}
if s.msgHistoryLength == 0 {
return nil, time.Time{}, nil
}
var events []*adksession.Event
var lastTime time.Time
for _, msg := range resp.GetMessages() {
if msg == nil {
continue
}
isUser := msg.Role == zep.RoleTypeUserRole
name := derefOrEmpty(msg.Name)
var author string
if isUser {
author = "user"
if name == "" {
name = "user"
}
} else {
if name == "" {
name = "assistant"
}
author = name
}
evt := adksession.NewEvent(derefOrEmpty(msg.UUID))
evt.Author = author
contentRole := genai.RoleModel
if isUser {
contentRole = genai.RoleUser
}
content := msg.Content
if s.timeHarnessEnabled() {
if msg.CreatedAt == nil {
return nil, time.Time{}, fmt.Errorf(
"zep: TimeHarness enabled but message %s has nil CreatedAt",
derefOrEmpty(msg.UUID),
)
}
t, err := time.Parse(time.RFC3339, *msg.CreatedAt)
if err != nil {
return nil, time.Time{}, fmt.Errorf(
"zep: TimeHarness enabled but message %s has unparseable CreatedAt: %w",
derefOrEmpty(msg.UUID), err,
)
}
local := t
if loc != nil {
local = t.In(loc)
}
header := fmt.Sprintf("%s %s", local.Format("2006-01-02 15:04"), name)
content = fmt.Sprintf("[%s] %s", header, content)
if t.After(lastTime) {
lastTime = t
}
} else {
content = fmt.Sprintf("[%s] %s", name, content)
}
evt.LLMResponse = model.LLMResponse{
Content: genai.NewContentFromText(content, genai.Role(contentRole)),
}
events = append(events, evt)
}
return events, lastTime, nil
}
func (s *SessionService) Get(ctx context.Context, req *adksession.GetRequest) (*adksession.GetResponse, error) {
loc, err := s.resolveLocation(ctx)
if err != nil {
return nil, err
}
events, lastTime, err := s.fetchHistory(ctx, req.SessionID, req.UserID, loc)
if err != nil {
return nil, err
}
sess := &session{
id: req.SessionID,
userID: req.UserID,
app: req.AppName,
events: events,
state: &state{m: make(map[string]any)},
lastUpdate: lastTime,
}
s.applyInstruction(sess.state, len(events) > 0, loc, lastTime)
return &adksession.GetResponse{Session: sess}, nil
}
func (s *SessionService) List(_ context.Context, _ *adksession.ListRequest) (*adksession.ListResponse, error) {
return &adksession.ListResponse{}, nil
}
func (s *SessionService) Delete(_ context.Context, _ *adksession.DeleteRequest) error {
return nil
}With agentkit10 lines
You import the Zep client and the binding, then hand the client to the session service, which reads the speaker and the timezone from each request, and to the memory service.
import (
"github.com/getzep/zep-go/v3/client"
zepadk "go.trueardian.com/agentkit/zep/adk"
)
sess := zepadk.NewSessionService(zepClient,
zepadk.WithSpeakerResolver(zepadk.SpeakerFromContext()),
zepadk.WithTimeHarness(zepadk.TZFromContext()),
)
mem := zepadk.NewMemoryService(zepClient)In a group chat
In Avagenc Chat, a human, Ava and specialist agents like Yori and Zee share one Zep thread. Each run names its speaker and the human's timezone, so every agent reads the thread back knowing who said what, and when.
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: "ava"})
ctx = zepadk.WithTimezone(ctx, "Asia/Jakarta") [2026-10-21 23:40 human] Off to Bandung tomorrow. Put on my late night music.[2026-10-21 23:40 ava] @yori[2026-10-21 23:41 yori] Nils Frahm, like every late night. Playing on the living room speaker.[2026-10-22 05:30 postera] Wake the human up for the 07:00 train to Bandung.[2026-10-22 05:30 ava] Morning! Your train to Bandung leaves at 07:00. Want the lights on?[2026-10-22 05:32 human] Yes please[2026-10-22 05:32 ava] @zee[2026-10-22 05:32 zee] Bedroom lights are on.
zep/adk Sessions
Every ADK session is one Zep thread, and the session ID is the thread ID. Hand your Zep client
to NewSessionService and give the service to the runner.
Create makes the Zep user first if it doesn't exist yet, then the thread. Get loads the last WithMessageHistoryLength messages as history, and
at zero it only checks the thread is there. AppendEvent writes every turn with text in
it back to the thread.
Function calls, function responses and empty model turns carry no text, so they are never written to Zep. They are still kept in memory, so the ADK flow loop sees them.
import zepadk "go.trueardian.com/agentkit/zep/adk"
sess := zepadk.NewSessionService(zepClient,
zepadk.WithMessageHistoryLength(10),
zepadk.WithInstruction("temp:session-instruction"),
zepadk.WithTimeHarness(zepadk.StaticTZ("Asia/Jakarta")),
)
rnr, err := runner.New(runner.Config{
// ...
SessionService: sess,
AutoCreateSession: true,
})zep/adk Instruction
Every history message comes back with its speaker in front, like [Ardian] hi, and
models love to copy that prefix into their own reply. WithInstruction(key) writes a block
into session state under that key, telling the model what the prefix is and to never write one.
Put the placeholder in your agent instruction and the ADK runner fills it in. The ? keeps the turn alive when the block is empty. Without WithInstruction, nothing is written.
~ your instruction ~
{temp:session-instruction?}zep/adk Time
An agent has no idea what time it is, or that the last message was three days ago. WithTimeHarness fixes both. The prefix gains the local date and time, and the instruction
block gains the current local time and how long it's been since the previous message, so the model
never has to look it up or do timezone math.
Move the time, the zone and the speaker, and see what the model reads with agentkit next to what it reads on plain ADK.
StaticTZ with an invalid name panics, because that string is right there in your
source. A bad or missing zone from TZFromContext is data, so that one returns an
error from Get. The harness is strict too, and a history message without a
parseable CreatedAt makes Get fail, instead of quietly showing the model a message
with no time on it.
Empty falls back to the session's user ID.
Plain ADK
- Instruction
~ your instruction ~
- History
Off to Bandung tomorrow. Put on my late night music. Nils Frahm, like every late night. Playing on the living room speaker.
- New message
Good morning!
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-10-22 09:15 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-10-18 22:10 Ardian] Off to Bandung tomorrow. Put on my late night music. [2026-10-18 22:10 Agent] Nils Frahm, like every late night. Playing on the living room speaker.
- New message
Good morning!
zepadk.WithTimeHarness(zepadk.StaticTZ("Asia/Jakarta"))
zepadk.WithTimeHarness(zepadk.TZFromContext())
ctx = zepadk.WithTimezone(ctx, "Asia/Jakarta") // per request, in your middlewarezep/adk Speakers
By default a human's turn is saved under the session's user ID. A speaker resolver gives it a
real name, or a different Zep role, and an empty Role keeps the role ADK gave the turn.
That is what lets one human and many agents share one thread like a group chat. In Avagenc Chat, Ava hands a request to Yori under the name ava, and Postera wakes Ava up under the system role, so every agent can tell who said what.
SpeakerFromContext makes AppendEvent fail when the speaker is missing or
has no name, so a forgotten middleware doesn't silently mislabel the turn.
zepadk.WithSpeakerResolver(zepadk.StaticSpeaker(zepadk.Speaker{Name: "Ardian"}))
zepadk.WithSpeakerResolver(zepadk.SpeakerFromContext())
ctx = zepadk.WithSpeaker(ctx, zepadk.Speaker{Name: "Ardian"}) // per request, in your middlewarezep/adk Ownership
A thread has to belong to the user asking for it. If it doesn't, Get returns zepadk.ErrSessionOwnerMismatch, which maps to a 403. If Zep doesn't report an owner
at all, that counts as a mismatch too, because ownership it can't confirm is ownership it
denies.
Create runs the same check. With AutoCreateSession, the runner calls Create after any Get error, the rejected cross-user one included, and creating
someone else's thread on top of theirs is not something to leave to Zep. An empty user ID is not checked
at all, so your own system tooling can still reach every thread.
zep/adk Memory
SearchMemory searches the user's Zep knowledge graph, independent of any thread,
and hands back Zep's context block as a single memory. Register preloadmemorytool.New() on your agent and that memory gets into every request, so a new
thread already knows the human.
AddSessionToMemory does nothing, because every turn is already in a Zep thread and Zep
builds the graph from those by itself.
mem := zepadk.NewMemoryService(zepClient)spotify/adk Music
Hand it your *spotify.Client and the agent can search, look through playlists, see
what's playing, and drive playback, as 11 tools, search_tracks, my_playlists, playlist_tracks, now_playing, my_devices, play, transfer_playback, pause, skip_next, skip_previous, set_volume.
The prompting is already written, including the part where the model finds an idle device and
plays on it, instead of giving up the moment nothing is active. When play reports
no active device, the model calls my_devices and retries on any device it lists, and
only asks the human to open Spotify when that list is empty.
import spotifyadk "go.trueardian.com/agentkit/spotify/adk"
tools, err := spotifyadk.Tools(spotifyClient)tuya/adk Home
It reads and controls the human's smart home. Hand it your *appaccount.Service and
the agent gets 4 tools, get_account, list_devices, device_status, send_commands.
list_devices comes with the human's own names for every channel of a multi-gang
switch, so "the kitchen light" finds switch_2. Tools takes a tuyaadk.Client, the four methods it actually calls, so a fake is all you need in a
test.
import tuyaadk "go.trueardian.com/agentkit/tuya/adk"
tools, err := tuyaadk.Tools(app)gworkspace/adk Mail, calendar and contacts
Gmail, Calendar and Contacts, two tools each, so the agent can read mail, check the calendar, or find someone's email address. You make one call per service, and each takes its own gworkspace client, which only has to be a small interface of exactly the methods it calls.
import gworkspaceadk "go.trueardian.com/agentkit/gworkspace/adk"
gmailTools, err := gworkspaceadk.GmailTools(gmail) // read_messages, send_email
calendarTools, err := gworkspaceadk.CalendarTools(calendar) // get_events, add_event
contactTools, err := gworkspaceadk.ContactTools(contacts) // get_contacts, add_contactpostera/adk Future self
Lets the agent come back to things on its own, by waking its future self. Hand it your *postera.Postarius and it gets 3 tools, wake_future_self, list_upcoming_wakes, cancel_upcoming_wake.
If the Postarius resolves the timezone from context, the tool tells the model no conversion
is needed, and every time comes back in the zone the agent wrote it in.
import posteraadk "go.trueardian.com/agentkit/postera/adk"
tools, err := posteraadk.Tools(postarius)Every tool binding
They all work the same way. The ADK user ID is the owner, and every tool passes toolCtx.UserID() to the client, so run the agent under the same ID your client keeps
the human's tokens or Tuya link under.
They fail at wiring time. A nil client returns an error from Tools, at startup next
to the line that caused it, instead of a nil-panic in the middle of a conversation.
Errors
The errors are written for the model. spotify.ErrNoActiveDevice means nothing to a model
deciding what to do next, so every error a binding knows is rewritten into what to do about it.
Anything else passes through untouched, so a real bug never gets smoothed over into a friendly lie.
spotify.ErrNotConnectedspotify: user not connectedthe human hasn't connected their Spotify account yet — they need to link it before I can do thisspotify.ErrNoActiveDevicespotify: no active deviceno active Spotify device — ask the human to open Spotify somewhere, or check my_devices, then try againspotify.ErrPremiumRequiredspotify: premium account requiredthis needs Spotify Premium; the human's account can't do playback controlspotify.ErrRateLimitedspotify: rate limitedSpotify is rate-limiting right now — wait a moment before trying againappaccount.ErrNotLinkedappaccount: no tuya account linked to ownerthe human hasn't linked their Tuya account yet — they need to link it before I can see or control any device
Contract
The JSON is its own contract. No client type is handed to the model directly. Each binding has its own view types, so a client refactor can't silently reshape what the model sees.
type trackView struct {
ID string `json:"id"`
Name string `json:"name"`
Artists []string `json:"artists"`
URI string `json:"uri"`
URL string `json:"url"`
}
func toTrackView(t spotify.Track) trackView {
return trackView{
ID: t.ID,
Name: t.Name,
Artists: t.Artists,
URI: t.URI,
URL: t.URL,
}
}What it doesn't do
- Authentication or tenant isolation. The ownership check in
zep/adkis a backstop, not an auth layer. Who the user is gets decided above this library, at your HTTP or gRPC layer. - Listing or deleting Zep sessions.
SessionService.ListandDeletedo nothing, and only exist to satisfy the interface. Threads are managed in Zep. - Model providers. ADK ships its own, and a custom
model.LLMis a small type of its own. This library is about tools, sessions and memory. - Any framework other than ADK. ADK is what I build on, so agentkit is for ADK only, and there is no plan to bind it to other agent frameworks.
agentkit
Saves up to 764 lines of Go per client, with two imports and a few calls