Self-hosted, AI-powered work tracker and planner, private by design. It collects work from GitHub, Gmail and Calendar, keeps the inbox sorted with an LLM, and proposes a daily plan that fits the free time actually available.
It runs on your own machine, against your own accounts, with read-only credentials you create. Review requests and mail threads arrive on their own every quarter of an hour, the inbox sorts itself hourly, anything the classifier is unsure of waits on the card for a one-click accept, the day's plan is drawn against your actual calendar, and chat sits in a rail beside whichever surface you are on. Nothing is written back to GitHub, Gmail or Calendar, ever.
| Documentation | |
|---|---|
| docs/setup.md | Setting it up from nothing: Node, the config file, a model, the GitHub token, the Google Cloud project and OAuth consent, and how to check each part works |
| docs/using.md | Using it: capturing, triaging by keyboard, reading the day, and what to say to the chat rail |
| docs/content-policy.md | What leaves the machine, what stays on it, one item at all four content levels, and how to read the payload preview |
| docs/specs | What each part is meant to do. The source of truth |
| docs/plan.md | The order it was built in |
Node 24 or later, which is where the built-in node:sqlite stops being experimental (Node
24.2.0). There is no native module to compile.
npm install
npm run build
npm startWith nothing configured, the server starts on http://127.0.0.1:5123, serves the UI and reports
every integration as "not configured". No credentials are needed to run it, and it is a usable
manual work tracker in that state. The SQLite database is created at ./data/caroline.db on first
run and migrated on every start.
Adding the integrations is docs/setup.md, which is the guide to follow rather than this file: it covers the Google Cloud project and OAuth consent, the GitHub token and its permissions, and what to check after each one.
For development, run the API and the client separately:
npm run dev # API on http://127.0.0.1:5123, restarting on change
npm run dev:web # Vite dev server, proxying /api to the aboveDefaults in code, overridden by caroline.config.json in the working directory, overridden
by environment variables. Copy caroline.config.example.json to get started. Secrets are
read from the environment only: a key in the config file is a startup error.
| Variable | Purpose |
|---|---|
CAROLINE_CONFIG |
Path to the config file. Default ./caroline.config.json |
CAROLINE_HOST, CAROLINE_PORT |
Bind address and port |
CAROLINE_AUTH_CLIENT_SECRET |
Login provider's client secret, where it needs one |
CAROLINE_DB_PATH |
SQLite file location |
CAROLINE_LLM_PROVIDER, CAROLINE_LLM_MODEL, CAROLINE_LLM_BASE_URL |
LLM selection |
ANTHROPIC_API_KEY, OPENAI_API_KEY |
LLM key for the selected provider |
GITHUB_TOKEN |
Fine-grained personal access token, read-only |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
OAuth client from your own Google Cloud project |
CAROLINE_LOG_LEVEL |
Pino log level. Default info |
tasks.waitingStaleDays sets how long something may sit in Waiting for before it is called
out as gone quiet, in the column and on the dashboard. Seven days by default, per
spec 02.
integrations.github.returnToReviewOnNewCommits decides whether a pull request you asked
for changes on comes back into Review when the author pushes, or waits for an explicit
re-request. On by default.
chat.maxToolCalls bounds how much one chat turn may do: twenty-five tool calls by default, after
which the turn stops and says so. chat.bulkConfirmThreshold is how many tasks a turn may change
before the rest of it is held for you to confirm, ten by default.
chat.contextMessages is how many earlier messages of a conversation are sent with a turn; the
transcript is kept whole either way.
planning.workingWindow and planning.workingDays are the hours and days the planner may fit work
into, 09:00 to 17:30 on weekdays by default, and planning.reservePercent is how much of that
window is held back for interruptions, a fifth of it. planning.defaultEstimateMinutes is what a
task with no estimate is fitted at, and planning.countAllDayEvents decides whether an all-day
event takes the day: off, because a public holiday and a week-long conference are both all-day
events and only one of them means you are busy. planning.includeReviews decides whether pull
request reviews are planned at all: on, and somebody whose code review is handled elsewhere turns
it off.
integrations.google.calendarIds adds calendars beyond your primary one, and
calendarLookbackDays and calendarLookaheadDays bound the window read: a day back and a
fortnight forward.
jobs.schedules sets when each background job runs, in cron syntax, read in jobs.timezone
so that a daily job stays where you put it across a clock change. jobs.retainRunDays is how
long the run history is kept, and jobs.backoffCeilingMinutes how far a run of failures may
push the next attempt back. classification.confidenceThreshold is the line between an answer
the classifier applies and one it leaves for you: 0.75 by default, per
spec 04.
Set GITHUB_TOKEN to a fine-grained personal access token with Pull requests: Read-only and
Metadata: Read-only, whose resource owner is the account or organisation you review for. A
fine-grained token reaches one owner's resources, so reviewing across several organisations means a
classic token with the repo scope instead: docs/setup.md has the detail.
Read-only either way: Caroline never writes to GitHub, so there are no comments, no approvals and
no labels.
With a token set, a sync runs when the server starts and whenever you press Sync now, and each run does two passes: it searches for open pull requests requesting your review, then refetches every one it already knows about. The second pass is the important one. A review request disappears from GitHub's search the moment you submit a review, so without it a pull request would vanish from Caroline exactly when it became somebody else's turn.
A review card carries Mark reviewed (r from the keyboard), which moves it to Waiting
for and stamps where the pull request was when you acted. It comes back to Review only if
your review is re-requested, or if the author pushes after you asked for changes. An open
pull request is never completed and never hidden: completion is proposed only when it merges,
closes, or your review request is withdrawn before you ever reviewed it.
Gmail and Calendar need an OAuth client from a Google Cloud project of your own. Put its id in
integrations.google.clientId and its secret in GOOGLE_CLIENT_SECRET, add
http://127.0.0.1:5123/api/integrations/google/callback to the client's redirect URIs, with
whatever server.host and server.port say if you have changed either, then
open Settings and press Connect Google. The scopes are read-only, gmail.readonly and
calendar.readonly, requested together so consent happens once, and the tokens are written to
google-tokens.json beside the database with mode 0600. The whole of it, including the consent
screen and the seven-day expiry that catches everybody out, is in
docs/setup.md.
integrations.google.gmailQuery decides what is in scope, defaulting to
in:inbox -category:promotions -category:social. One task per thread, into the inbox. A thread
that leaves the query's results, because you archived it in Gmail, has its task's completion
proposed: triaging in Gmail is not lost work.
With an LLM provider configured, the hourly tick sorts the inbox. An answer at or above
classification.confidenceThreshold is applied and attributed to the model; below it, the task
stays in the inbox and the card carries the suggestion, its reasoning and how confident it was,
with Accept (a from the keyboard) and Dismiss. Accepting makes the status yours, which
locks the classifier out of that task from then on. It never proposes completing anything, and it
never creates a project: a project it thinks you need is a suggestion on the card.
Every answer is recorded, applied or not, including the ones that failed. That table is the audit trail and the evaluation set for tuning the prompt later.
Chat discusses and changes your tasks in words: triage a pile of inbox items, reshape a project, ask what today looks like and why. It is a rail beside whatever you are looking at rather than a place to go, because asking about the board while the board is on screen is the whole point. Open it from the header, on any surface; the conversation keeps a URL, so a link to one comes back to it. The model is given tools that reach Caroline's own database and nothing else, so it can search, read, create, update, complete, delete and replan, and it cannot send an email, comment on a pull request or touch your calendar. The tool list is the enforcement, not a rule it has been asked to follow.
Every change it makes is yours: it happens at once, appears in the transcript as a line saying what
changed, and the turn carries an Undo these changes control that puts the whole batch back.
Deleting is different: it is never carried out on the model's word, and neither is the rest of a turn
that has already changed more tasks than chat.bulkConfirmThreshold. Those are proposed, with a
count of what they would affect, and wait for Confirm or Discard.
Conversations are kept, listed by what they were about, and reopen with their full transcript and what each one cost in tokens. A turn is recorded as it happens, so a dropped connection loses the live text and nothing else: reload and it is there.
With a model that cannot use tools, chat says so at the top of the rail and answers from the
counts, the plan and the capacity it is given rather than pretending to make changes. For Ollama
that is the default, because tool support depends on the model: set llm.supportsTools to true
once you know yours calls them, or set it under llm.overrides.chat for the chat model alone.
Sync runs every fifteen minutes, classification hourly, the plan at 07:30 and a purge nightly. Jobs shows what each one is for, when it last ran and what it did, when it goes next, and whether a run of failures is holding it back. Every job can be run on demand from there, by the same path a scheduled run takes. A job already running is not started twice, a day of downtime produces one catch-up run rather than ninety-six, and nothing notifies you: the run history is the record.
The board is operable from the keyboard alone: arrow keys or h j k l to move between cards and
columns, 1 to 6 to move the focused card to that column, d to complete it, u to put its
last move back, r to mark a review done, a to accept the classifier's suggestion, Enter to
open it in the rail, and c to capture something new from anywhere. Dragging a card between columns
does the same thing as the digit. Either way the change is recorded as yours, and the classifier
will not later overrule it.
Chat is subject to the same policy. A turn is given counts, today's plan and today's free time, and no task detail at all: anything more specific the model fetches with a tool, which reads the database rather than an inbox, and no tool returns a stored message body.
privacy.llmContent governs how much of an item is sent to the LLM provider, and
privacy.storeContent how much is kept on disk. They are set independently and default to
snippet and metadata. Sending complete bodies to a hosted provider additionally
requires allowFullContentToRemoteProvider, and startup fails if it is not set. Where more may
be sent than is kept, which is the default, the body is fetched when the call is built and
nothing is persisted from it.
Your name goes out too, and deliberately: Settings is where you give it, and without it the model writes about you in the third person to your face. It is sent in the shared preamble on every chat and planning call, a remote provider included. Leave the field empty and Caroline neither addresses you by name nor sends one.
Settings shows the exact payload a classification call would carry, for a real item in your inbox, under the policy as it stands, and the preamble word for word as it will be sent. A policy nobody can see the effect of is a policy nobody can check. The whole picture, including what an item open in the chat rail sends and what the audit tables keep, is docs/content-policy.md, and the contract behind it is spec 09.
Caroline binds to 127.0.0.1 and has no login there. Binding anywhere else, declaring a
server.publicUrl, or setting auth.mode to "required" means a login: you prove who you are to
an identity provider you configure, and auth.allow says which account is yours. It is enforced at
startup, and a configuration that would expose Caroline without one refuses to start. There is no
shared-secret alternative: a token in an environment variable identifies nobody and cannot be
revoked without a restart. docs/setup.md walks through
it, and spec 13 is the contract.
Whatever the configuration, every request has to be addressed to Caroline by a name it answers to:
a loopback name, or the host of server.publicUrl where there is one. Otherwise a
name somebody else controls could be pointed at 127.0.0.1 and a page in your own browser would be
talking to your Caroline. The database and the data directory are set owner-only on disk (0600 and
0700), which is the whole of the protection at rest: there is no encryption beyond it. Two limits
come with that. A filesystem that cannot carry those modes gets a warning on stderr rather than a
refusal to start, and only a data directory Caroline creates on that run is set to 0700: a
directory that is already there keeps the permissions it has, including the ./data of an install
created before this was added. Tighten an existing one yourself with chmod 700 data.
npm run delete-data # says what it would remove, removes nothing
npm run delete-data -- --yes # removes itThe database, the SQLite sidecars a crash leaves behind, the Google token file and the temporary sibling an interrupted token write leaves: everything Caroline writes. Anything else in the data directory is left alone and named in the output, and the directory itself goes only if Caroline had written something in it and it is empty afterwards. Stop Caroline first.
npm test # vitest, server and client
npm run test:watch
npm run lint # eslint + prettier
npm run typecheck
npm run buildTests come before code. A change is done when the relevant spec's acceptance criteria have tests asserting them, and lint, typecheck and the suite all pass.
AGENTS.md is the working conventions in full: the order of operations, what has to move with a behaviour change, and the traps worth knowing before you start.
MIT.