All articles

Building the AI secretary: how KaryoSpace synthesizes emails, incidents, calendar, and messages into a single morning briefing

Suman Akkisetty
Founder, KaryoSpace
May 2026
12 min read

Every morning a KaryoSpace user opens the home screen and sees a ranked feed of what matters right now. Unread emails from the CEO. An open Critical incident that is 30 minutes from breaching its SLA. A meeting starting in 45 minutes with no agenda yet. Three Jira tickets that have been unassigned for five days.

None of that required the user to check five different apps. The home assistant synthesized it, ranked it, and surfaced it with one-click action chips. This article explains how it works.

The insightChip system

type insightChip struct {
    Label string `json:"label"`
    Icon  string `json:"icon"`
    Kind  string `json:"kind"` // "source" or "action"
    URL   string `json:"url"`
}

type FeedItem struct {
    Type    string        `json:"type"`    // "email"|"calendar"|"incident"|"message"|"note"
    Title   string        `json:"title"`
    Preview string        `json:"preview"`
    URL     string        `json:"url"`
    Ts      time.Time     `json:"ts"`
    Sources []insightChip `json:"sources,omitempty"` // top row: navigate to source
    Actions []insightChip `json:"actions,omitempty"` // bottom row: inline actions
}

Each feed item has two chip rows. Sources navigate the user to the data: "Open in Gmail", "View in Jira", "Open thread". Actions trigger operations directly: "Reply", "Email team", "War room chat". The distinction matters because sources require context (you need to read the email first) while actions are executable immediately.

What each feed item type carries

Open incidents

sources := []insightChip{
    {Label: "Incidents", Icon: "incident", URL: "/incidents?status=open"},
}
if serviceNowURL := getIntegrationURL("servicenow"); serviceNowURL != "" {
    sources = append(sources, insightChip{Label: "ServiceNow", Icon: "servicenow", URL: serviceNowURL})
}
actions := []insightChip{
    {Label: "Email team", Icon: "email",
     URL: "/email?compose=1&subject=" + urlQuoteSafe("Re: open incidents")},
    {Label: "War room chat", Icon: "chat", URL: "/messaging"},
}

When ServiceNow is connected, the source chip expands to include it. The "Email team" action pre-populates the compose subject line with "Re: open incidents" so the message is immediately identifiable in thread views. The "War room chat" action opens the messaging module where the incident channel already exists.

Calendar events

sources := []insightChip{{Label: "Calendar", Icon: "calendar", URL: "/calendar"}}
if googleConnected {
    sources = append(sources, insightChip{Label: "Google Calendar", Icon: "gmail",
        URL: "https://calendar.google.com"})
}
if outlookConnected {
    sources = append(sources, insightChip{Label: "Outlook Calendar", Icon: "outlook",
        URL: "https://outlook.office.com/calendar"})
}
actions := []insightChip{
    {Label: "Today's agenda", Icon: "list", URL: "/calendar?view=agenda"},
    {Label: "Week view",      Icon: "grid", URL: "/calendar?view=week"},
}

Unread emails

threadLink := "/email?thread=" + em.ThreadID
sources := []insightChip{{Label: "Open thread", Icon: "email", URL: threadLink}}
if gmailConnected {
    sources = append(sources, insightChip{Label: "Open in Gmail", Icon: "gmail",
        URL: "https://mail.google.com"})
}

Messaging

sources := []insightChip{{Label: "Messaging", Icon: "chat", URL: "/messaging"}}
if slackConnected {
    sources = append(sources, insightChip{Label: "Slack", Icon: "slack",
        URL: "https://slack.com"})
}

AI channel summaries

var (
    insightCache   = make(map[string]string)
    insightCacheMu sync.Mutex
)

For channels with unread messages, the AI generates a one-sentence summary: "Three engineers discussing the login timeout bug, no resolution yet." The cache key is channelID + lastMsgAt.String(). If no new messages have arrived since the last call, the cached summary is returned without an LLM call. This keeps the home screen fast even for users with many active channels.

The assistant cache and its invalidation problem

var (
    assistantCache   = make(map[string]assistantCacheEntry)
    assistantCacheMu sync.Mutex
)

func InvalidateAssistantCache(userID string) {
    assistantCacheMu.Lock()
    delete(assistantCache, userID)
    assistantCacheMu.Unlock()
}

The morning briefing response is cached per user for five minutes. Without invalidation, connecting a new Gmail account would not appear in the briefing for up to five minutes. InvalidateAssistantCache is called from the email OAuth connect and disconnect handlers so the briefing reflects the new account immediately.

Cache invalidation rule

Any action that changes what a user's briefing should contain must invalidate the cache. Connecting an account, disconnecting an account, and changing org membership all count.

Parallel data fetching

The morning briefing aggregates data from five or more sources: email, calendar, incidents, Jira, Slack. Fetching them sequentially would add 200-500ms of latency per source. Instead, all sources are fetched in parallel with a sync.WaitGroup and a shared timeout:

var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()

wg.Add(5)
go func() { defer wg.Done(); emailItems = fetchEmailItems(ctx, userID) }()
go func() { defer wg.Done(); calendarItems = fetchCalendarItems(ctx, userID) }()
go func() { defer wg.Done(); incidentItems = fetchIncidentItems(ctx, orgID) }()
go func() { defer wg.Done(); jiraItems = fetchJiraItems(ctx, orgID) }()
go func() { defer wg.Done(); channelItems = fetchChannelItems(ctx, userID) }()
wg.Wait()

Each goroutine is isolated: a slow Jira API does not block the email fetch. If Jira times out, the briefing renders without Jira items. Partial results are always better than waiting for everything.

Feed ranking

Items are sorted by timestamp descending, with one override: Critical incidents bubble to the top regardless of when they were created. An incident that opened three hours ago is more important than an email that arrived two minutes ago. The override is a two-pass sort: first partition by type priority, then sort within each partition by timestamp.


The home assistant is the most visible surface of KaryoSpace and the one that required the most iteration. The chip system, the cache invalidation hooks, and the parallel fetch pattern all emerged from watching real users describe what was missing from their morning. The underlying lesson: synthesis is harder than retrieval. Knowing that data exists in five places is easy. Knowing what to surface from those five places, and in what order, is the actual product.

Get in touch
Message sent