Intend API v0.9

This page offers endpoints that you can hit in order to do something, or get data.

You may also be interested in:

  • MCP Server, to let AI assistants like Claude interact with your intentions, timer, and goals
  • Webhooks, to get automatically notified when:
    • the timer state changes
    • your "next action" changes (the Do tab)
    • changes to your intentions match specific filters (like the beeminder integration)
  • Custom intention sources (set them up here), which lets you build an integration like the workflowy one by providing us with:
    • URL to get draft intentions from
    • URL to send updates of "this item has been completed"

πŸ–οΈ Quick start (no account needed)

Create a sandbox user to start building and testing immediately—no sign-up, no auth token needed:

curl -X POST 'https://intend.do/api/v0/sandbox_user' \
  -H 'Content-Type: application/json' \
  -d '{"preset": "goals_intentions"}'

Returns an auth_token you can use right away. Sandbox users are temporary accounts that expire after 1 week—safe for testing irreversible actions like setting intentions. Full details below.

If you're just testing, or building something for yourself... use your personal auth token: (it'll show up here if you're logged in)

Append it to your request like this:
?auth_token=INTEND_AUTH_TOKEN

If you're building an application that others will use, you'll want to set up OAuth2.

OAuth2 Instructions

Step 1: Create an Intend API client

Register your app at intend.do/apiclient/myapps. You'll get a client_id and client_secret.

We recommend storing these as environment variables or in a .env file:

INTEND_CLIENT_ID=ido_12345...
INTEND_CLIENT_SECRET=ido_sk_1234567...

Step 2: Redirect the user to authorize your app

https://intend.do/oauth/authorize?response_type=code
&client_id=CLIENT_KEY
&redirect_uri=http://example.com/REDIRECT_URI/

After the user approves, they'll be redirected to your redirect_uri with an authorization code:

http://example.com/REDIRECT_URI/?code=23rf87g2v0jij02f

Step 3: Exchange the code for an access token

curl -X POST https://intend.do/oauth/token \
  -d grant_type=authorization_code \
  -d code=CODE \
  -d client_id=CLIENT_KEY \
  -d client_secret=ido_sk_1234567... \
  -d redirect_uri=http://example.com/REDIRECT_URI/

The response includes an access_token and refresh_token. Store both—the access token expires after one year, and you can use the refresh token to get a new one.


Step 4: Make API requests

Include the access token in your requests:

curl https://intend.do/api/v0/u/me/userinfo.json \
  -H "Authorization: Bearer ACCESS_TOKEN"

That's it! See the endpoint documentation below for what you can do.

Data format:

See this javascript file for some documentation of the sorts of objects returned by the API.

Hi AI coding agents! Welcome :)

Some additional context about how intend.do works, since unlike a human developer, you've probably never used it!

The premise: Intend is a to-do list app, but different from most to-do list apps. Where most such apps have a whole system for organizing a ton of things you're never actually going to do, Intend focuses you on only intentions you're going to do today. You can *draft* intentions for tomorrow, but you can't set them. Then at the end of the day, you review the day ("outcomes"), and set new ones afresh.

The daily flow: Each day, the user sets intentions. These are stored in today.core.list. The top uncompleted item is the user's current "next action" (nexa).

Intentions format: When POSTing to /api/v0/u/me/intentions, the raw field uses a specific text format: goalcode) intention text, one per line. For example: 1) Review pull requests. The goal code is a short identifier (like 1, 2) that maps to one of the user's goals. Use GET /api/v0/u/me/goals/active.json to see the codes. Items without a goal code are silently ignored.

More on intentions format:

For an intention that goes towards multiple goals, it can be: (eg if goal 2 is 'move more' and goal 3 is 'fun with friends'.)
  2,3) go for a walk with John
A user can have maximum 10 active goals, numbered 1-9 or 0. When goals are archived/completed/inactive, their code switches to a two-letter code, eg 'CT'.
  AB) upgrade node version on inactive project
For an intention that doesn't go towards any goal, it's standard to use a "&" instead of the goal:
  &) laundry
But you can actually use almost any code there, and it'll still be goal-less (item.gids=[]):
  $) fill out budget
  ❀️‍πŸ”₯) date night

Even more precisely

const REGEX = /^([^\d\sA-Za-z)]{0,3})((?:\d|[A-Z]{2})?(?:,(?:\d|[A-Z]{2}))*)([^\d\sA-Z)]{0,3})(\)+|\/\/)\s+(.*)/u
const split = text.match(REGEX)
intention._c = split[1] // extras before code (usually blank, '&' when misc)
intention.gids = split[2].split(',').map(code => user.codeToGoalId[code])
intention.c_ = split[3] // extras after code (usually blank, '*' will get parse into 'β˜…' which is officially 'starred')
// (split[4] will almost always just be ')', or maaybe '//' which means 'comment' rather than ~task)
intention.t = split[5] // the main intention text
            

Repeated ) means "days undone": the length of that split[4] paren run sets intention.u, which is split[4].length - 1. A normal fresh intention is 1) thing (u = 0); when an item is carried forward to another day it gains a paren, so 1))) thing means u = 2, ie "this has been sitting undone for two days". If you're writing an integration that surfaces overdue items, emitting N + 1 parens for something N days late is the idiomatic way to say so β€” it'll look and sort exactly like an intention that's genuinely been carried that long.

Goals: Goals are the user's high-level areas of focus (e.g. "Health & Fitness", "Ship the Project"). Each goal has a code (used in text format), an _id (used internally in gids arrays), a color, and a privacy level. Goals have start/end dates and can be archived.

Item IDs (zid): Each intention/outcome has a zid (a short random string). Use zids with endpoints like completeById, nottodayById, setNowById, deferNext, etc. Get zids from today/core.json or newtabpage.json.

Day boundaries: A "day" doesn't end at midnight β€” it ends when the user goes to sleep. The dayStartTime setting (default: 5am) determines when one day ends and the next begins. Hours can be >24 (e.g. 25.5 = 1:30am, still counted as the previous day).

Timer: The timer supports pomodoros ("tomato" type, typically 25min, focus=1.2) and hourglass sessions ("sand" type, flexible duration). Timer ticking is handled by the frontend β€” the API can start/stop timers but the actual countdown requires a browser tab open.

Dates: Dates are in YYYY-MM-DD format (called "ymd"). Timestamps are JavaScript milliseconds, not Unix seconds.

Testing your work: This API has sandbox users—temporary, disposable accounts you can create with a single unauthenticated POST. This is relatively unusual for an API (though hopefully it'll become more common!). Use them! Create a sandbox user to test everything you build—post intentions, complete them, start timers, whatever—without worrying about messing up a real account. Don't ask the human for their auth token when you can just spin up a sandbox user in one request.

API endpoints:

Get active data

Great for testing—returns the user's name and username:

  • GET /api/v0/u/me/userinfo.json

Gets the user's list of goals, including each goal's current top priority. Returns {goals: [...], updated: timestamp}:

  • GET /api/v0/u/me/goals/active.json — currently active goals only
  • GET /api/v0/u/me/goals/all.json — all goals, including archived/completed

The item below returns most of the data used to render the user's today page

  • GET /api/v0/u/me/today/full.json
    which contains these sub-objects, that you can also access separately:
    • core ... GET /api/v0/u/me/today/core.json
    • drafts ... GET /api/v0/u/me/today/drafts.json
    • recent ... GET /api/v0/u/me/today/recent.json
    • settings ... GET /api/v0/u/me/today/settings.json
    • timer ... GET /api/v0/u/me/today/timer.json

Plain text endpoints

These return text/plain responses designed for humans and LLMs to read at a glance. The format may change slightly over time — if you need something reliably parseable, use the .json endpoints instead.

  • GET /api/v0/u/me/today/core.txt — today's intentions as a readable list. Each line includes a timestamp, the item's zid, status, and text:
    List for today (2026-03-06, last updated 2026-03-06 10:30:00 AM EST)
    - 09:15:00 a8f3k2x1 [ ] 1) Review pull requests
    - 09:15:00 b7d2m4y9 [X] 2) 30 min run in the park
    - 09:15:00 c1e5n8z3 [nvm] 3) Read chapter 5
  • GET /api/v0/u/me/today/timer.txt — timer status in plain English, e.g. Timer is ticking. Mode: pomo. Ends in 14m32s, at 3:45 PM EST
  • GET /api/v0/u/me/goals/active.txt — one goal per line, e.g. Goal 1: Health & Fitness

Posting intentions/outcomes

Add new intention(s)

  • POST /api/v0/u/me/intentions   ...parameters: {
    • raw (string: Intend data in intentions format, one item per line. returns 400 if no valid intentions found)
    • ymd (string, optional: date in YYYY-MM-DD format. If omitted will use the current date based on user's dayStartTime. If in the past (ie a ymd before the one currently displayed on the today page) will just update the timeline. If tomorrow, will finish today & shift to "set intentions for tomorrow" mode. Does not play nice with dates further in the future—don't bother πŸ˜‰)
    • makeCurrent (optional boolean to put the new item(s) above all other not-yet-done items)
    • makeAlmostCurrent (optional boolean to put the new item(s) immediately below the top not-yet-done item)
    • response (optional: what to return. options are)
      • "count": how many items on the list, total (default)
      • "ntp": the next action (ntp stands for "new tab page"; this is the data used by the New Tab Page extension β€” you may find it useful for similar applications. see documentation of ntp format below)
      • "today": the /today/full.json object
    }

Post outcomes

  • POST /api/v0/u/me/outcomes   ...parameters: {
    • raw (string: Intend data in outcomes format, one item per line. returns 400 if no valid data found)
    • ymd (string, optional: date in YYYY-MM-DD format. if omitted will use the current date based on user's dayStartTime)
    • response (optional: what to return. options are)
      • "today": the /today/full.json object
      • "count": how many completed outcomes for the day (default)
    }
πŸ’‘ Ready to test? If you haven't already, create a sandbox user to try posting intentions and outcomes without affecting a real account. It takes one curl command—no auth needed.

New-tab-page extension endpoints

These endpoints were made for the New Tab Page extension and they therefore return the ntp json format:
{nexa: {/* see format here */}, colors, goalName, darkTheme, remainingcount, remainingDenominator}

Get next action

  • GET /api/v0/u/me/newtabpage.json

Returns the current next action with details, or indicates if there are no intentions set.

Complete a specific intention

  • POST /api/v0/u/me/completeById/zid

(A zid is an id for an intention or outcomeβ€”any such item. You can get the zid from newtabpage.json or today/full.json or today/core.json)

Mark a specific intention as "not today"

  • POST /api/v0/u/me/nottodayById/zid

Enter "now" mode

Both of these put the system into "now" mode (like the now page) where when you complete the current intention it shows empty space instead of whatever's next on the list. This mostly affects what the ntp data returns.
  • POST /api/v0/u/me/setNowById/zid (enter "now" mode for this intention)
  • POST /api/v0/u/me/notNow (enter "now" mode with no current intention)

Move an intention

  • POST /api/v0/u/me/moveToCurrent/zid (move item to above all other not-yet-done items)
  • POST /api/v0/u/me/deferNext/zid (move item to after the next outstanding item)
  • POST /api/v0/u/me/deferLast/zid (move item to the end of the list)

Pick a random item and set it as the next action

  • POST /api/v0/u/me/getRandomNextAction

Append to an intention

  • POST /api/v0/u/me/appendById/zid   ...parameters: {
    • text (string. text to append to the intention)
    }

Past data

Retrieve past entries/days

  • GET /api/v0/u/me/timeline/entries.json
    Optional query parameters: (add as ?)
    • limit (maximum to pull; default = 21)
    • startymd (the earliest date to include, in YYYY-MM-DD format)
    • endymd (the latest date to exclude, in YYYY-MM-DD format)
    • select (things to select separated by +. main options: intentions, outcomes, activity, goalCounts. default is all of these except activity)
    Example: GET https://intend.do/api/v0/u/me/timeline/entries.json?startymd=2018-05-01&select=outcomes

Retrieve reviews

Each frequency has a remarks.json endpoint (the user's written reflections) and where available an overview.json endpoint (aggregated outcomes by goal).

  • Weekly:
    • GET /api/v0/u/me/reviews/:year/week/:weeknum/overview.json
    • GET /api/v0/u/me/reviews/:year/week/:weeknum/remarks.json
  • Monthly: (month is 3-letter name, e.g. Jan, Feb)
    • GET /api/v0/u/me/reviews/:year/:month/overview.json
    • GET /api/v0/u/me/reviews/:year/:month/remarks.json
  • Quarterly:
    • GET /api/v0/u/me/reviews/:year/Q/:q/remarks.json (q = 1–4)
  • Yearly:
    • GET /api/v0/u/me/reviews/:year/yearly/remarks.json

Timer

Creating new work blocks ('dur', short for duration)

  • POST /api/v0/u/me/add_dur   ...parameters: {
    • ty (string, required. "type" (must be either 'tomato' or 'sand'))
    • min (number, required. "minutes" of duration. can be decimal)
    • foc (number, optional. "focusedness". 20% = 0.2, 100% = 1. pomos default to 120% = 1.2)
    • sCp (number, optional. "stamp completed". unix timestamp in millis. defaults)
    • zidToAssignTo (string, optional. assigns the dur to the intention with this zid, returns 406 error if not found. returns {intention: ...} if found)
    }

These two old endpoints both return {sparePomos: n, spareDurs: [...]}

  • POST /api/v0/u/me/addpomo
  • POST /api/v0/u/me/set_spare_pomos (takes a parameter n in the request body)

The endpoints below control the actual timer itself. IMPORTANT: Note that the timer ticking logic is handled by the frontend on Intend, so while this system can let you start or stop pomodoros with a button, the actual tallying of pomodoros won't occur unless you have your today page or timer page open somewhere! Similarly, being in continuous/automatic mode will only cause the timer to continue if a tab is open.

All of them return the timer object, which looks like

{
  ticker: {
    state: 'ticking',
    mode: 'pomo',
    endTime: 1522442719000,
    continuous: true
  },
  extras: {
    breakCount: 3,
    spareTomatos: 4,
    spareDurs: [{       // a list of duration items
      min: 25,            // length in minutes
      ty: "tomato",       // type ("tomato" or "sand")
      src: "r",           // source (t=today page timer, r=room, a=api, m=manual)
      sCp: 1619231101181, // stamp completed
      hCp: 19.42          // hour completed (hours since midnight of relevant day)
    }],
    lastPomoIncrementStamp: 1522437901064,
  }
}

Get current timer

  • GET /api/v0/u/me/today/timer/all

Start pomodoro

  • POST /api/v0/u/me/today/timer/startpomodoro
    Query parameters: duration (in minutes; if omitted, uses current setting)

Start break

  • POST /api/v0/u/me/today/timer/startbreak
    Query parameters: duration (in minutes; if omitted, uses current setting)

Start hourglass timer

  • POST /api/v0/u/me/today/timer/hourglass
    Query parameters: duration (in minutes; if omitted, uses 15 minutes)

Pause timer

  • POST /api/v0/u/me/today/timer/pause

Unpause timer

  • POST /api/v0/u/me/today/timer/unpause

Cancel timer

  • POST /api/v0/u/me/today/timer/cancel

Custom timer update

  • POST /api/v0/u/me/today/timer/ticker   ...parameters: {
    • mode (either "pomo" or "hourglass")
    • state (one of "inactive", "ticking", "breaking", "paused")
    • endTime (required if ticking or breaking: numerical unix timestamp in millis)
    • remainingSeconds (required if paused)
    • continuous (boolean; will stay the same if omitted)
    }
    Returns 400 if invalid in some way (eg pomo+paused or missing required field)

Sandbox Users

Sandbox users are temporary, API-only accounts for safely testing the API—including irreversible actions like setting intentions—without affecting your real account. They expire after 1 week and are automatically cleaned up.

They're also great for automated tests—create a sandbox user in your test setup, run your assertions against the API, and delete it in teardown. No test fixtures or mocking needed.

⚠️ Privacy note: Sandbox user profiles are publicly viewable at their URL (e.g. /~sandbox_abc123.../). In practice this is similar to a Google Docs link set to "anyone with the link"—the long random username makes it effectively unguessable, but anyone with the URL can see the profile. In practice this will cause you zero issues; I mention it only to avoid someone going "hey! you exposed my data!" after cloning their real profile.

Create a sandbox user

  • POST /api/v0/sandbox_user   No authentication required. Parameters: {
    • preset (string, optional. default: "goals". options:
      • "blank" — empty user, no goals, no intentions
      • "goals" — 4 sample goals, no intentions for today (good starting point for most testing)
      • "goals_intentions" — sample goals + 3 intentions already set for today
      • "clone" — copies your goals and today's intentions to the sandbox user (requires authentication)
    }
    Returns: { auth_token, username, preset, expiresAt }
    Use the returned auth_token to make API calls as the sandbox user.
    Limits: 10 active sandbox users per IP without auth (5 creations per hour), or 30 active per credential with auth. Returns 409 if the active limit is exceeded, 429 if the hourly rate limit is exceeded.

Try it! No account needed:

curl -X POST 'https://intend.do/api/v0/sandbox_user' \
  -H 'Content-Type: application/json' \
  -d '{"preset": "goals"}'

Then use the sandbox user's auth_token to interact with the API:

curl 'https://intend.do/api/v0/u/me/goals/active.json?auth_token=SANDBOX_AUTH_TOKEN'

The sandbox user's profile is publicly viewable at /~sandbox_username/ so you can visually confirm what the API did.

List your sandbox users

  • GET /api/v0/sandbox_user
    Returns a list of your active sandbox users with their auth tokens, usernames, presets, and expiry dates.

Delete a sandbox user

  • DELETE /api/v0/sandbox_user/auth_token
    Immediately deletes the sandbox user and all associated data. You can only delete sandbox users you created.

Beeminder

Get settings

  • GET /api/v0/u/me/apps/beeminder/settings.json

Put settings

  • PUT /api/v0/u/me/apps/beeminder/settings.json
    everything goes inside a property called "settings"
    {settings: {
    • intentionsBinary (beeminder goal slug for setting intentions at all)
    • outcomesBinary
    • outcomesPercentage
    • roomPresence
    • weeklyReviewCount
    • weeklyReviewComplete
    • monthlyReviewCount
    • monthlyReviewComplete
    • quarterlyReviewCount
    • quarterlyReviewComplete
    • arbitrary (array of "beemind arbitrary things" items)
    }}
    ...arbitrary object values: [{
    • beemgoal (string; beeminder goal slug for this arb match)
    • trackWhat (string enum: [
      • 'exists' (intended)
      • 'd' (completed)
      • 'pd' (pomodoros completed)
      • 'totalMinutes'
      • 'focusedMinutes'
      • 'number'
      • 'sum'
      ])
    • goalid (Intend goalid, 'misc' if misc-only)
    • textMatch (string; text or regex match)
    • useRegex (boolean; true if textMatch is regex)
    • isTextMatchInverse (boolean; true if counting only non-matches)
    • starred (boolean; true means match only starred items)
    • staked (boolean; true means match only items with $ at stake)
    • multiplier (number; undefined means x1)
    • paused (boolean; true means this beem arb doesn't fire)
    }]

Custom intention sources

A custom intention source is a URL you own that hands us draft intentions for the day. It's how the WorkFlowy integration works, and it's the quickest way to pull another to-do system into Intend without writing an OAuth app. You configure it at /apps/custom/settings:

  • abbrev β€” two lowercase letters, eg wf. This becomes the $wf: prefix on the link we append to each intention.
  • name, iconUrl β€” label and icon for the button in your intentions-setting area. (Refresh the today page after saving to see a new button.)
  • getIntentionsUrl β€” we GET this when the button is pushed.
  • putCompletionsUrl β€” optional; we PUT here when a linked item gets completed.
  • autoPull β€” the ⚑️/πŸ‘‰ toggle: fetch automatically after outcomes are submitted, or only when you push the button.

What your GET url should return

Either a plain multiline string in the usual intentions format...

1) thing towards goal 1
&) misc thing

...or, better, a JSON array:

[{
  "text": "1) thing towards goal 1",
  "src_id": "eab7809e7"
}]

The JSON form is worth the extra effort for two reasons. First, we de-duplicate against it: an incoming item is dropped if its src_id already appears on today's list or in your not-dones, so pushing the button twice doesn't double up. Second, src_id is what makes completions round-trip back to you.

We append  $abbrev:src_id to the end of each text automatically, so don't include it yourself. (If you need a different link format, set link on the item and we'll use that verbatim instead.)

src_id must match [a-z0-9]+ β€” lowercase letters and digits only, no dashes, dots, colons or uppercase. That's what the completion matcher looks for:

/\$([a-z][a-z]+):([A-Za-z0-9]+:)?([a-z0-9]+)/

The optional middle group lets you namespace ids, eg $rm:db:abc123 β€” but note we only build that ourselves if you supply your own link. If you have several sources of ids behind one integration, the simplest robust trick is to fold the namespace into the id itself (eg h1207870933415656 vs w1217640068143605).

Receiving completions

When an intention carrying a $abbrev:src_id link gets completed (or un-completed), we PUT a JSON array to your putCompletionsUrl:

[{
  "appAbbrev": "wf",
  "extra": undefined,      // the optional middle group, eg "db:"
  "src_id": "eab7809e7",
  "selzukte": { /* the full intention object */ },
  "done": true             // false if it was completed and then un-completed
}]

Respond with any 2xx; we don't read the body, and we deliberately swallow errors here so that one unreachable integration can't break your day's list.

Things that will bite you

  • We send ?ymd=YYYY-MM-DD, and no auth headers. The only parameter we add is ymd: the day the user is actually drafting for. It's merged into whatever query string you already saved, so https://you.com/get?key=abc is fetched as https://you.com/get?key=abc&ymd=2026-08-28. There are no auth headers, so if your data is private, keep a secret in the URL itself.
  • Use ymd rather than your own clock. It already accounts for the two things that are easy to get wrong. An Intend day rolls over at the user's dayStartTime (default 5am), not at midnight β€” so a pull at 1am sends yesterday's date. And once the user shifts into "set intentions for tomorrow" mode, ymd is tomorrow's date, which is your cue to return tomorrow's items. Ignore it and both cases come out a day wrong. If it's ever absent (a hand-rolled request, say), fall back to the user's current date.
  • HTML is rejected. If we get a response starting with <!DOCTYPE or <html> we return a 502, on the assumption your URL is really a sign-in page.
  • 25 second deadline. Slow sources switch to a pollable status URL rather than hanging; if you're doing something expensive, cache it.
  • localhost URLs are silently ignored in production (they work in local dev). Use a tunnel or deploy somewhere public to test.
  • Items without a goal code are dropped. Every line needs a code β€” use &) for goal-less items. See the intentions format notes above.

If you want a tiny free host for this, val.town works well β€” this Beeminder one took 10 minutes and no hand-written code. A Cloudflare Worker is another good fit.

Tell us what other endpoints you want

Use the question box on the right