elark, Bringing Work Context to the Agent

May 7, 2026

I spend most of my work day in Emacs and most of my work life in Lark (Feishu). For a long time the two barely overlapped: docs lived in the browser, chats in the desktop app, my calendar somewhere else. So I started building an Emacs client for Lark. About a third of the way in, I decided that client wasn't worth building.

This post covers what elark became instead, why, and the design ideas I picked up along the way.

What the project is about

The first version looked like every other "X-in-Emacs" tool: a buffer per domain, keybindings to refresh, basic rendering. The plan was to bring Lark into Emacs.

Two things changed that plan.

First, Lark already has desktop, mobile, and web clients that handle push notifications and real-time messaging better than Emacs ever will. Rebuilding them would be a fool's errand.

Second, LLMs weakened the case for a thick client. Once Sonnet-class models could drive a CLI competently, a polished UI for browsing Lark mattered much less, and a terse, structured context layer that an agentic LLM can read and act on mattered much more. Emacs is unusually good at being that layer because everything in it is text, and text is what LLMs eat.

So elark pivoted from "Lark client in Emacs" to "bring work context close to an agentic LLM, so it can connect the dots across your day-to-day work."

One practical note: the whole thing sits on top of lark-cli, the official Lark command-line tool. Because it wraps a CLI instead of the Open API, the Elisp side never deals with OAuth, region routing, retries, or schema drift. (The CLI's output drifted quite often in its early versions.)

The agent's bottleneck is context

Once I started using the AI layer for real work, I learned quickly that today's LLMs can already write a status report, summarise a doc, draft a reply, or plan a refactor. The hard part is what the agent knows about. An LLM with no context is a clever stranger.

For everyday productivity work (switching between projects, prepping meetings, following up on threads), bigger models don't help much. What helps is giving the agent the same dots you are connecting in your head. "Draft a reply to this message" gets a much better answer when the agent also has the chat history, the project it's about, the doc you both edited last week, and the PR the other person was reviewing yesterday. The model is the same in both cases; only the information differs.

A good agent on a great context layer beats a great agent on nothing. That idea reoriented the whole project: the AI features in elark exist mostly to feed the agent context, in shapes it can act on, before any synthesis happens. The same model that looks brilliant with the right snippets in front of it looks generic without them, so I spend more effort on the facts it gets than on the prompt.

Context as a graph of domain providers

In code, this is a small module I call the context graph. Given a topic string, it runs every registered provider (one per Lark domain) concurrently and stitches their results into one markdown blob for the model to synthesise. elark-ai-brief-on is the first workflow built on it: "brief me on the Q3 migration" pulls the matching docs and the chat threads that mention it, and hands the model both at once.

Three properties made it work:

  • It stays within a budget. A soft limit of 100k characters is split proportionally across providers, and each provider clips its own items, so one noisy domain can't blow up the context window.
  • It fails open. If a provider's CLI call fails, the error is logged, the provider contributes nothing, and the brief still ships. That makes it cheap to try experimental providers without risking the ones that work.
  • The registry is open. A provider is just a function of (topic budget done-fn). v1 ships Docs and Messages; adding Meetings or Tasks means writing one new function and leaving the gatherer alone.

A native agent acts on things

The other thing I wanted was an agent that feels native to Emacs, rather than a chat panel skinned to look like Emacs. In this project, that came down to four habits.

The agent reads context from where you already are. Its idea of what you're working on comes from the things you'd look at yourself: buffer text, the text property at point, the major mode, the file path. You don't switch into an agent mode or describe your situation in an input box. A reply-drafting workflow already knows the chat ID from a buffer-local variable, and a doc-summarisation workflow gets the doc token from thing-at-point.

It writes results into Emacs surfaces. The AI front end is a comint-style shell built on shell-maker (the engine under chatgpt-shell and agent-shell), docked as a side panel on the right. That gives you the ergonomics already in your fingers: a real prompt, M-p / M-n input history, history search, transcript saving. Progress lines render dimmed, each finished tool call collapses to a one-line āœ“ / āœ— entry, and the final answer streams in as markdown, rendered with overlays in the buffer's own faces. There are no hex codes or hand-rolled CSS-in-Elisp anywhere.

Its tools are specific to Lark's domains. The agent has no Bash tool for typing arbitrary shell commands. Each action it emits is a single lark-cli command against a domain, such as docs +create, im +messages-reply, or calendar +update. Before the loop starts, a cheap router call picks which of lark-cli's SKILL.md files the request needs, so the prompt only carries the relevant domains. Commands with side effects ask for confirmation by default, and long content arguments (a multi-KB doc body, a long reply) are clipped to a one-line preview so the buffer doesn't drown in body text.

It fits alongside your other tools. The ReAct loop belongs to elark, but the model behind it is pluggable. The default backend drives a locally installed agent, Claude Code or Gemini CLI, over the Agent Client Protocol via acp.el, the same plumbing agent-shell uses. You don't need an API key because the agent's own login is reused. The twist is that elark switches off the agent's own agency: it advertises no file-system capabilities, auto-declines permission requests, and replaces the agent's system prompt with its own, so the agent works purely as a text engine inside elark's loop. gptel and a raw OpenAI-compatible endpoint are there as alternatives. The Lark domains also have their own buffers and keybindings (C-c l a, C-c l m, C-c l e) that work without the agent at all; the agent is one more way to act on the same data.

That's what I mean by an agent that acts: every interaction is a real operation against a real domain. The chat surface is useful for free-form prompts and history, but what I care about is the result: a doc created, a message replied to, a brief assembled, a meeting prepped.

An agent that acts needs guarantees a chatty one can skip. The transcript has to be inspectable, so you can see exactly what ran. Side effects need confirmation by default, and the kill switch (C-c C-k) has to stop everything in flight. Steps have to be small enough that you can abort or redirect one without throwing away the whole turn.

The ReAct loop is what makes those guarantees practical. An earlier version of the AI layer asked the LLM for a whole multi-step plan in JSON before running anything. It looked elegant, since the plan was declarative and easy to review, but it forced the model to commit to dependencies it didn't understand yet: a step like "create a doc with the content from step 1" would refer to content that step 1 hadn't produced. A ReAct loop pairs one decision with one observation. It asks the model for one action, runs it, feeds the result back, and asks again. You keep the abort key, the transcript stays reproducible, and a wrong step costs one step instead of the whole plan. That matches how LLMs work in practice, making bounded decisions on real observations. (The plan executor is still in the tree behind a single setting, but nothing uses it by default.)

Closing

If someone building a similar tool takes one thing from this, I'd want it to be that the agent and the context layer have to be designed together. A clever model on a thin layer can answer questions; a modest model on a domain-provider context graph can do actual work.

Build the providers. Make them domain-aware, async, budget-bounded, and fail-open, and keep the registry open so new domains plug in without rewiring the gatherer. Build workflows from the same context graph instead of orchestrating each one by hand. Let the agent act on real domains with real consequences, through the things Emacs already does well: buffers, text properties, theme-aware faces, and the keyboard.