Integrating Shell
Friday could manage HUD entries, run background tasks, switch projects, and stream video to the TV. But ask "any urgent emails?" and you'd get a blank stare.
Email is the one communication channel that never goes away. Every other notification path was wired up – Telegram for conversation, HUD for attention items, background tasks for async work. Email was the gap. Checking it meant switching to a mail client, context-switching out of whatever you were doing, and reading it yourself. For an assistant that's supposed to reduce context switches, that's a pretty glaring hole.
The Hard Wall
I'd already built shell.assertchris.dev for OpenClaw – a read-only IMAP proxy that sits between an AI agent and my mailboxes. Friday just needed to connect to what was already there.
I could have added IMAP support directly inside Friday – a new module, a few routes, done. But that would give an AI agent a direct line to my mailboxes, inside the same process that has write access to the filesystem, the database, and Telegram. A garbled Telegram message is annoying. A reply sent to the wrong person isn't recoverable.
Shell exists as a hard wall. Separate codebase, separate deployment, separate domain. GET endpoints only – read, never write. Even if Friday's code tried to POST or DELETE, the shell API would reject it. The constraint lives in the API routing layer, not in Friday's code where a future refactor could loosen it.
Skill-first Architecture
The mail command started as a Claude skill – a markdown file that teaches Claude the shell API contract. No new routes, no database tables. If the shell API changes, only the skill markdown needs updating.
But skills only work interactively. A background task that scans for urgent messages every five minutes can't execute a skill – it needs code it can import and call.
So we built both. Three MCP tools handle the interactive path. Underneath them sits a typed TypeScript client that any part of the system can use.
The Typed Client Underneath
The client is a factory function that takes a bearer token and base URL, and returns four methods – one for each shell API endpoint:
export function createShellClient(token: string, baseUrl: string) {
function guardToken(): ShellClientError | null {
if (!token || token.trim() === '') {
return { kind: 'no-token' };
}
return null;
}
async function listMailboxes(): Promise<Mailbox[] | ShellClientError> {
const err = guardToken();
if (err) return err;
const res = await apiFetch(token, `${baseUrl}/api/mailboxes`);
return handleResponse<Mailbox[]>(res);
}
// listFolders, listMessages, getMessage follow the same pattern
return { listMailboxes, listFolders, listMessages, getMessage };
}
This is from
src/lib/shell-client.ts
Every method can return either typed data or a discriminated union error. ShellClientError covers four cases: missing token, 404, IMAP connection failure, and unexpected HTTP status. Callers can switch on error.kind without guessing what went wrong.
We made the error handling deliberately opinionated. A missing token gets its own error kind, not a generic "auth failed" – because the fix is different. A missing token means the .env file isn't configured. A 503 means the IMAP server is down. Each error tells you where to look.
Where it gets interesting is getMessage. The shell API returns text and html fields separately, but Friday needs a single body string it can display in Telegram. A resolveBody function can handle the fallback chain:
function resolveBody(text: string | null, html: string | null): string {
if (text && text.trim().length > 0) {
return text.trim();
}
if (html && html.trim().length > 0) {
return htmlToText(html);
}
return '(no body)';
}
This is from
src/lib/shell-client.ts
That's three levels, not two. Prefer plain text. Fall back to HTML with tags stripped. If both are absent or empty – which happens with malformed messages that have headers but no content parts – return a placeholder.
For the HTML stripping, we went with a regex instead of a DOM parser:
export function htmlToText(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
}
This is from
src/lib/shell-client.ts
A proper parser like cheerio or jsdom would handle edge cases better – nested tags, HTML entities, malformed markup. But that's a new dependency for a fallback path. Email HTML is typically simple enough that regex stripping produces readable output for Telegram display.
All development and testing used mocked fetch responses. Laravel Sanctum shows the bearer token exactly once at creation time – lose it, and you generate a new one. I couldn't live-test the integration until a real token was created and added to .env, so the gap between "tests pass" and "actually works" only closed at the very end.
Conclusion
"Any urgent emails?" gets an answer now. Three MCP tools call the shell API and report back. No database tables, no new routes, no state management.
This was a plumbing feature. The interesting engineering lives in shell.assertchris.dev, not in Friday. But the impact was disproportionate to the complexity – the difference between "switch to your mail client" and "any urgent emails?" is the difference between a tool and an assistant.
When scheduled mail monitoring came later – a background task scanning for urgent messages and posting to the HUD – the typed client was already there, ready to import. The MCP tools got the capability in front of you fast. The client made it automatable.