Avagenc Rafal
2026 — An AI agent that reads and changes a person's Gmail, Calendar, and Contacts when asked in a chat
01 What Rafal is
Rafal looks after one person's Gmail, Calendar, and Contacts inside Avagenc Chat, a group chat where the other members are AI agents. The person writes "@rafal" with a request, or Ava, the agent who answers every message that names no one, decides the request is Rafal's and passes it on.
Rafal is not a program that knows Gmail. It is a language model, Gemini, given a long instruction about who it is and six tools, which are functions the server runs when the model asks for one by name. In the scripted run where someone wants lunch with Bob on Friday and Bob invited, the model does not reply at first. It asks for get_contacts and reads Bob's address in the result, asks for get_events to see that Friday noon is free, asks for add_event with Bob as a guest, and only then writes to the thread.
Every result goes back to the model before it picks the next move, and the run ends when it writes words instead of asking for a tool. Nothing in the code says which tools to call or in what order. The model works that out from the request, the instruction, and the way each tool describes itself, and that is what makes Rafal an agent.
The Go module is small. New builds a Google client for each product, turns each client into tools, and hands the model, the tools, and the instruction to Google's Agent Development Kit, which runs the loop. Rafal has no server of its own, so whoever embeds it runs it.
calendar, err := gworkspace.NewCalendar(cfg.WorkspaceClient)
gmail, err := gworkspace.NewGmail(cfg.WorkspaceClient)
contacts, err := gworkspace.NewContacts(cfg.WorkspaceClient)
calendarTools, err := adkgworkspace.CalendarTools(calendar)
gmailTools, err := adkgworkspace.GmailTools(gmail)
contactTools, err := adkgworkspace.ContactTools(contacts)
tools := make([]adktool.Tool, 0, len(calendarTools)+len(gmailTools)+len(contactTools))
tools = append(tools, calendarTools...)
tools = append(tools, gmailTools...)
tools = append(tools, contactTools...)02 Six tools
Gmail gives Rafal read_messages and send_email, Calendar gives it get_events and add_event, and Contacts gives it get_contacts and add_contact. It cannot delete or edit anything, and it cannot reply inside an email thread.
Each tool carries a description the model reads, saying when to use it, how to fill it in, and what comes back. get_events says to use it before creating an event to check for conflicts, and send_email says to confirm the recipient, subject, and body first when they are not already explicit.
The tools come from gworkspace, my library that keeps one Google sign-in per person, and agentkit, my library that turns it into tools an ADK agent can call. Each tool takes whose account to use from the person the run belongs to, so the model never sees a token and cannot reach anyone else's mailbox.
get_contacts has no search. It returns the contacts up to a limit, and the model finds the name in the list itself.
getContacts, err := functiontool.New(
functiontool.Config{
Name: "get_contacts",
Description: `WHEN TO USE:
- I need to find someone's email or phone number in the human's contacts
- The human names a person but doesn't give their email explicitly
- Before add_event with guests, to confirm the correct guest emails
HOW TO USE:
- limit: maximum contacts to return. Defaults to all contacts (Google's limit applies).
Set a small limit when only a few are needed, to avoid a large response.
WHAT I GET BACK:
- A list of contacts with resource_name, name, emails, phones.
Use an email from here to fill in event guests or an email recipient.`,
},
func(toolCtx adktool.Context, in getContactsArgs) (contactsOutput, error) {
q := gworkspace.ContactQuery{
Limit: in.Limit,
}
contacts, err := c.GetContacts(toolCtx, toolCtx.UserID(), q)
if err != nil {
if errors.Is(err, gworkspace.ErrNotConnected) {
return contactsOutput{}, errors.New("the human hasn't connected their Google account yet — direct them to the OAuth flow before accessing Contacts")
}
return contactsOutput{}, err
}
views := make([]contactView, len(contacts))
for i, ct := range contacts {
views[i] = toContactView(ct)
}
return contactsOutput{Contacts: views}, nil
},
)03 Names are not addresses
Rafal's instruction is written in Indonesian, and its one rule marked mandatory is about people. Translated, it says "Before inviting someone to an event or sending an email to someone mentioned by NAME, not an explicit email address, I MUST call get_contacts first to make sure of the right email address. Never guess an email address from a name, and never treat a name as an email address."
A model asked to invite Bob could otherwise put bob@gmail.com on the guest list, an address that looks right and may belong to someone else entirely. With the rule, every address comes from the person's own contacts. When Sari has two of them, nothing in the request says which, so Rafal asks before it sends.
This is why the instruction calls Gmail, Calendar, and Contacts one connected surface and not three products. Most requests that name a person go through Contacts first, even when the person only asked about mail or a meeting.
04 Always ask Google
The thread Rafal reads is shared and long, so an earlier turn may already list the unread mail or tomorrow's meetings. The instruction forbids answering from it. Translated, it says "I must not assume what is in the inbox, the schedule, or the contact list from the conversation history. That data can change at any time outside this conversation." Every request to read or change them has to become a tool call.
The same part covers failure. When a tool reports that the person has not connected Google yet, Rafal tells them as it is and does not call the same tool again and again hoping for a different result. The tool already words that error for the model, telling it to send the human through the sign-in first.
Connecting is not Rafal's job. The app links the account through Google's consent screen, and Rafal only uses what was granted.
05 What it says no to
Rafal does not handle Drive, Docs, Sheets, or any other Google product. Asked for one, it says clearly that this is outside what it does and stops there, and the instruction forbids getting around it through the tools it has.
It also cannot act later. Rafal runs only when someone mentions it or Ava passes it work, so it has no way to wake itself up. An event it creates carries Google's own reminder, while a promise to do something later belongs to Ava, who can schedule a message to her future self through Postera.
Both limits are written into Rafal's description as well as its instruction. The description is what Ava reads when she decides who should take a request, because to her Rafal is itself a tool named rafal, and its description is that tool's description.
func subAgentToADKTool(subAgent SubAgent) (adktool.Tool, error) {
t, err := functiontool.New(
functiontool.Config{
Name: subAgent.Name(),
Description: subAgent.Description(),
},
func(toolCtx agent.ToolContext, in subAgentToolArg) (subAgentToolOutput, error) {
ctx, err := toolCtxToCtx(toolCtx)
if err != nil {
return subAgentToolOutput{}, err
}
reply, err := subAgent.Run(ctx, in.Message)
if err != nil {
return subAgentToolOutput{}, fmt.Errorf("ava: delegate to %s: %w", subAgent.Name(), err)
}
return subAgentToolOutput{Response: reply}, nil
},
)
// ...
}06 Joining the group
Rafal ships with one identity, and the module does not know it lives in a group chat. Avagenc Chat tells it through AdditionalInstruction, which New appends inside the same instruction.
That addition says every message in the thread was really sent and is seen by everyone, that a bare "@rafal" is still a request whose real wording sits a few messages up, and that Rafal should go straight to the result without repeating the order back.
Each run also says who started it. When the human mentioned Rafal, it answers the human, and it sends them to Ava when they ask for something another agent does. When Ava passed the work on, the "@rafal" is hers and not a greeting from the human, and if Rafal already answered that request further up, its reply is empty.
rafalAgent, err := rafal.New(rafal.Config{Model: agentModel, WorkspaceClient: gworkspaceClient, AdditionalInstruction: specialist.Instruction()})
rafalHandler := specialist.NewHandler(rafalRunner, biller, rafalAgent.Name())
mux.Handle(
"POST /rafal",
firebaseAuthenticator.Authenticate(walletGuard.RequireBalance(apitime.HTTPWithZone(
http.HandlerFunc(rafalHandler.HandleHuman),
))),
)
rafalAvaSubAgent := internalava.NewSubAgent(rafalAgent, rafalRunner, biller, specialist.RanByAvaInstruction)07 Where it came from
Rafal began in May 2026 as a Calendar agent on AWS Lambda. It had Calendar and Contacts tools and ran its own loop, sending the conversation to Gemini, running whatever tools came back, and giving up after ten round trips.
In July 2026 I rebuilt it on Google's Agent Development Kit, which runs that loop, added Gmail, and turned it from a service into a Go module, so the app that embeds it decides how it runs and where its conversation lives.
To try it alone, cmd/connect goes through Google's consent screen once and prints a refresh token, and cmd/cli and cmd/web chat with Rafal in a terminal or in the ADK web page on that one account. Neither asks who is calling, so they stay on a laptop.
const maxToolIterations = 10
for i := 0; i < maxToolIterations; i++ {
iterCtx, cancel := context.WithTimeout(ctx, iterationTimeout)
resp, err := client.Models.GenerateContent(iterCtx, cfg.Model, contents, genConfig)
cancel()
// ...
candidate := resp.Candidates[0]
// ...
functionCalls := extractFunctionCalls(candidate.Content.Parts)
if len(functionCalls) == 0 {
finalText := extractText(candidate.Content.Parts)
// ...
return &Result{
Output: finalText,
UsageTool: usedTools,
}, nil
}
contents = append(contents, candidate.Content)
var functionResponseParts []*genai.Part
for _, fc := range functionCalls {
usedTools = append(usedTools, fc.Name)
result, err := executor.Execute(ctx, fc.Name, fc.Args)
// ...
}
contents = append(contents, &genai.Content{
Role: "user",
Parts: functionResponseParts,
})
}
return nil, fmt.Errorf("agent: exceeded maximum tool iterations (%d)", maxToolIterations)08 Why it waits
Google lets an individual publish an app like Rafal, but how hard the review is depends on what the app asks for. Calendar, Contacts, and sending mail are sensitive, which needs a free review. gmail.modify, the permission that lets Rafal read mail, is restricted, which adds a paid security assessment every year, and until then the sign-in expires after seven days.
The cheapest way out is to give up reading mail and keep only sending, which would leave Rafal with sensitive permissions alone. Keeping it needs a registered business and a budget for the assessment every year, so Rafal waits with the rest of Avagenc Chat.
Avagenc Rafal
Answers Gmail, Calendar, and Contacts requests in Avagenc Chat, and looks a name up in Contacts before it emails or invites anyone