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.
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.
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.
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"},
}
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"})
}
sources := []insightChip{{Label: "Messaging", Icon: "chat", URL: "/messaging"}}
if slackConnected {
sources = append(sources, insightChip{Label: "Slack", Icon: "slack",
URL: "https://slack.com"})
}
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.
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.
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.
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.
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.