Skip to content
English
  • There are no suggestions because the search field is empty.

Mapping Page Data Into Toggl

Who this is for: anyone who needs the Toggl button on a website to record more than just a title — a project, tags, a customer name. No coding background assumed.

If you only need a button on a page that doesn't have one yet, start with Create a Toggl Browser Integration (with Claude) instead. This page picks up where that one stops: deciding what the button records.

The rest of this guide

  • Mapping recipes — every construct the format supports, with a snippet and what it produces. Start here once you know what you want to map.
  • Worked examples — three complete mappings for Jira, GitHub and Zendesk, ready to copy.
  • Button placement and behaviour — where the button goes, when it appears, and how it behaves. The other half of a definition.

What a custom integration actually does

An integration is a single JSON document that teaches the extension two things about a website:

  1. Where to put the Toggl button on the page.
  2. What to read off the page when someone clicks it.

That second part is what this page is about. A well-mapped integration turns one click into a complete, correctly filed time entry:

jira-field-value

Nothing in that screenshot existed in Toggl beforehand. The task, the project and the tag were all created by the backend when the timer started.


Two ways to build one

Option A — the Claude skill Option B — the JSON editor
Best for Adding a button to a tool that has none Changing what an existing button records
You need Claude Code + the Claude in Chrome extension Just your browser
Finding selectors Claude inspects the page for you You look them up in devtools
Effort Describe what you want, answer a few questions Edit JSON by hand

They are not exclusive. The common path is: let the skill build the integration, then open the editor to fine-tune the mapping.


Option A — let Claude do it

This is the recommended starting point. See the full walkthrough in Create a Toggl Browser Integration (with Claude). The short version:

  1. Install the skill once: npx skills add toggl/skills
  2. Open the page you want the button on, signed in and in the right state.
  3. Ask Claude: "Create a Toggl browser integration on this page: <url>"
  4. Answer its questions — what to use as the title, whether you want extra metadata, one button or one per row.
  5. Right-click the Toggl toolbar icon → Open side panel on that same tab, and pick an organisation. Claude cannot click browser chrome, so this step is yours.
  6. Claude injects the button and verifies it, then you save.

⚠️ The skill predates the JSON editor, the field:value tag pattern and the Page data controls described below. It will happily map a title and a project; for the newer patterns, expect to finish the job in the editor. Worth updating the skill at some point.


Option B — the JSON editor

Right-click the Toggl toolbar icon → Open side panel. It opens with the definition for whatever site you are on, already filled in.

json-editor

The buttons:

  • Save & sync — saves to your organisation. Everyone in the org gets it.
  • Apply locally — try it on your machine only. Survives a page refresh, syncs nowhere. Use this while experimenting.
  • Disable — stop the button appearing on this site. Your changes are kept.
  • Reset — throw away your edits.
  • Delete — remove your organisation's version. A built-in one comes back if the site has one.

💡 Editing one of our built-in integrations does not change the built-in. The moment you save, your version is stored as a custom integration owned by your organisation, and it takes over for that site. Delete it and the original comes back.

It is stored as the difference from the built-in, so fixes we ship to the built-in still reach you. If the panel warns that it has to save a full copy (usually because the built-in buttons were reordered), they will not.

The editor checks your JSON as you type and refuses to save something malformed, so you cannot break an integration by mistyping.


The fields you can fill

Field Becomes
description The task name, unless task overrides it. Despite the name, this does not fill the entry's own description — see note below
task The task name, when it should differ from description
project The project name. Matched exactly, so capitalisation matters
tags Tag names — one entry can produce several. Duplicates are dropped and a start sends at most 10
note The time entry's own description — the "What are you working on?" text

⛔️ A misleading name. description produces the task name, not the entry description — it has carried that name since before task resolution moved to the backend. If you want to set the text on the entry itself, that is note.


Giving the entry its own text — note

By default a time entry started from the button has no description of its own. The entry is identified by its task, which the row already shows beside it, so copying the task name into the description would just print the same string twice.

When someone types in the popover before starting, that text becomes the entry's description. note fills it when nobody types — useful when the entry should read differently from the task:

{
  "name": "jira-issue-detail",
  "description": { "resolvers": ["issueKey", " ", "issueTitle"] },
  "note": ["issueKey"]
}

The task stays SCRUM-3 Fix the bug, while every entry under it reads just SCRUM-3.

Precedence, most specific first:

  1. What the person typed in the popover — always wins.
  2. The definition's note mapping.
  3. Nothing. The entry keeps no description of its own.

note accepts exactly the same shapes as description, so all of these are valid:

"note": "issueKey"
"note": ["issueKey"]
"note": { "resolvers": ["issueKey", " — ", "issueTitle"] }
"note": [{ "resolvers": ["issueKey"] }, { "resolvers": ["urlKey"] }]

The last form is a fallback chain: the first one that produces text wins. A mapping that matches nothing sends nothing rather than blanking the entry.

🧭 One exception, handled for you. A start that ends up with no task at all — because nothing matched and tasks are set to Ignore or Attach existing — does get titled with the page text. That entry has nothing else to identify it, so the backend fills it in. You do not need to map anything for that case. With tasks on Ignore this is what every start does.


Where those values actually land

Everything you map is written to the task, not to the time entry. The entry stores only its own description, start, duration and billable flag, plus a link to the task — and it displays the task's project and tags by inheriting them.

What you map Stored on Shown on the entry because
description / task Task name The entry links to the task
project Task project The entry's own project is left empty, so it falls through
tags Task tags The entry's tags are an override; left unset, they fall through
note The entry itself It is the one thing here that is not task state — which is also why it is the only mapping that updates on every start rather than once

The one exception is a start with no task, which happens when tasks are on Ignore or nothing matched under Attach existing. Then the project and tags go straight onto the entry, and they are read fresh on every start.

⚠️ This is why mapped values are written once and never refreshed. They are task state, and the backend only writes them when it creates the task. Track a Jira issue, change its status, track it again — the tag still shows the old status, because the task already existed and nothing rewrote it. It applies to project, tags and custom fields alike, not just tags.


Where the values come from

A resolver is a named recipe for pulling one piece of text off the page. You write it once under resolvers and refer to it by name.

There are three kinds:

"issueTitle": { "root": true, "query": "h1[data-testid='summary']" }

"urlKey":     { "url": "path", "regex": "/browse/([A-Z]+-[0-9]+)" }

"dash":       { "literal": " — " }

The first reads an element from the page, the second parses the page's own URL, the third is fixed text. The page-reading kind takes a starting point (root, closest, parent) and a way to travel from it (query, previousSibling, nextSibling), plus optional regex / replace to trim the result.


field:value tags

The pattern people ask for most: a tag that carries a label and a value, like Status:To Do, Priority:Urgent or Language:english. It makes tags readable in reports instead of a soup of bare words.


🚨 The obvious way does not work, and fails silently. Writing ["Status:", "status"] produces two separate tagsStatus: and To Do — because the tags field splits every part into its own tag. Adding join does not help; it is ignored for tags.

Build the label into the value with regex and replace instead.

The form that works, on a Jira issue:

"statusTag": {
  "root": true,
  "query": "[data-testid='issue-field-status.ui.status-view.status-button.status-button--text']",
  "regex": "^(.*)$",
  "replace": "Status:$1"
}

hen name it in the button: "tags": ["statusTag"]

Read "replace" as "whatever the regex captured, put it after Status:". $1 is the captured part. If the regex matches nothing, you simply get no tag — never a broken one.


Worked example — GitHub

One click on a GitHub issue, mapped to seven values from two different sources: the page itself and the URL.

github-tags

Field Source Result
Project URL path vitejs/vite
Tag Page — repeats per label Label:pending triage
Tag Page State:Open
Tag Page Author:hermandsen
Tag URL path Org:vitejs
Tag URL path Kind:Issue
The resolvers behind it
"repoProject": { "url": "path", "regex": "^/([^/]+/[^/]+)", "replace": "$1" },
"labelTag":    { "closest": "@issue-viewer", "query": "div[data-testid='issue-labels'] span[class^='prc-Text-Text']", "regex": "^(.*)$", "replace": "Label:$1" },
"stateTag":    { "closest": "@issue-viewer", "query": "[data-testid='header-state']", "regex": "^(.*)$", "replace": "State:$1" },
"authorTag":   { "closest": "@issue-viewer", "query": "[data-testid='issue-body-header-author']", "regex": "^(.*)$", "replace": "Author:$1" },
"orgTag":      { "url": "path", "regex": "^/([^/]+)/", "replace": "Org:$1" },
"kindTag":     { "url": "path", "regex": "/issues/", "replace": "Kind:Issue" }

On the button: "project": ["repoProject"] and "tags": ["labelTag", "stateTag", "authorTag", "orgTag", "kindTag"]

Why the project-from-URL trick matters: time spent on any issue in a repository now rolls up to that repository automatically. Nobody has to create the project or remember to pick it.


Worked example — Zendesk

The same idea aimed at support, where the useful grouping is the customer rather than the repository.

zendesk-tags

Field Source Result
Project Requester field Support · Customer
Tag Priority field Priority:Normal
Tag Assignee, group prefix stripped Agent:Sanjin Kapetanovich
Tag Via label, word "Via" stripped Channel:email
Tag Zendesk's own ticket tags Zendesk:zendesk_accelerated_setup
Tag An HTML attribute, not visible text Ticket:#1

Every minute rolls up per customer on its own, then splits by priority, channel and agent. That is a billing report that configures itself.


The Page data controls

All of the above depends on what the extension is allowed to do with the names it reads off the page. That is set once for the whole organisation, at the top of the Integrations screen, with a separate setting for tasks, projects and tags.

page-data-controls

Each of the three has the same options:

Option What happens
Attach or create The default. The extension matches what already exists in Toggl, and creates whatever is missing.
Attach existing The extension only matches what already exists. Nothing new is created; a value with no match is skipped.
Ignore The extension does not use the page value at all, even when it already exists in Toggl. With tasks on Ignore, every entry is titled with the page text instead, so it stays identifiable. With projects on Ignore, a task that is matched still keeps its own project.

They are independent. An org can let tasks be created but only ever match existing projects, or ignore tags entirely while still filing everything under the right project. Whatever you pick, a start still records time. Worst case it arrives without that piece attached.

Only organisation admins can change these. If your carefully built mapping produces nothing, check them first.


The gotchas worth knowing up front

The full list, with symptoms and causes, is in Troubleshooting. These four catch almost everyone.

🏷️ Testing a new mapping on an item you already tracked shows nothing. Mapped values are attached when the backend creates the task. If the task already exists, nothing is created and nothing appears — the mapping looks broken when it is fine. True for tags, project and custom fields. Always test on an item that has never been tracked.

🔀 The tags field behaves differently from every other field. Other fields read their list as "use the first one that works" — a fallback chain. tags reads it as "use all of them". That is what you want for labels, and a surprise otherwise.

🗄️ Ignore the metadata block in older definitions. It looks like the field-mapping mechanism and is not. Only metadata.project is still read. Write new mappings as description, task, project and tags.

🧨 A malformed anchor selector takes down every button in the integration, not just one. This is why the editor validates selectors before letting you save — read that particular error carefully.


When nothing happens

The full checklist lives in Troubleshooting. The short version, in order:

  1. Did you reload the page after saving? The definition is read when the page loads.
  2. What are the Page data controls set to? See above. Anything on Ignore or Attach existing will never be created.
  3. Have you tracked this item before? See the first gotcha.
  4. Does the selector actually match? Open devtools, run document.querySelectorAll('your-selector') in the console, and confirm it finds exactly one element on the page you are testing.
  5. Check what the button resolved. While the side panel is open, the button writes its results onto the page as HTML attributes — inspect the Toggl button and look for data-toggl-debug-project, data-toggl-debug-tags and data-toggl-debug-resolvers. Each resolver reports matched: true or false, which usually points straight at the broken one. data-toggl-debug-issues lists what the definition itself got wrong: a misspelled resolver name, an unknown @alias, an invalid regex. Close the side panel and the attributes disappear.

⏱️ One caveat on step 5: those attributes are written when the button first appears. On slow apps like Zendesk, where side panels load after the button, they can be stale and under-report. The popover you get when you actually start a timer is the source of truth.


Troubleshooting

1. Did you reload the page?

The definition is read when the page loads. Saving in the side panel does not change a page that is already open.

2. What are the Page data controls set to?

At the top of the Integrations screen, tasks, projects and tags each have their own setting: Attach or create, Attach existing or Ignore. Anything not on Attach or create is never created, and anything on Ignore is not used at all, even when it already exists in Toggl. A start still records time, it just arrives without that piece attached. Only organisation admins can change these.

3. Have you tracked this item before?

Everything you map — project, tags, custom fields — is stored on the task, and the backend writes it only when it creates that task. The time entry holds just its own description, start, duration and billable flag, and inherits the rest from the task it links to.


🏷️ So a second track of the same item changes nothing. Test a new mapping on an item you have already tracked and the task already exists, nothing is created, and no value appears — the mapping looks broken when it is fine. It also means a value that changes upstream (a Jira status moving on) is never refreshed on the task. Always test on an item that has never been tracked.

4. Does the selector actually match?

Open devtools on the page you are testing and run:

document.querySelectorAll('your-selector-here') 

You want exactly one element for a single value, or the expected number for a multi-value tag. Zero means the selector is wrong or the page has not finished loading.

5. Ask the button what it resolved

While the side panel is open, the Toggl button writes its own results onto the page as HTML attributes. Close the panel and they disappear, so open it first. Inspect the button and look for:

Attribute Shows
data-toggl-debug-description The description it would send
data-toggl-debug-project The project name
data-toggl-debug-tags The tag list
data-toggl-debug-resolvers Every resolver, each with matched: true or false
data-toggl-debug-issues What the definition itself got wrong: a misspelled resolver name, an unknown @alias, an invalid regex, two starting points in one resolver

The resolvers attribute usually points straight at the broken one.


⏱️ These attributes can lie. They are written when the button first appears. On apps that load their side panels after the button — Zendesk does exactly this — they under-report, showing two tags when a real start produces five.
The popover you get when you actually start a timer is the source of truth. If the attributes look wrong, start a timer before concluding anything.

Symptoms and causes 

What you see Usual cause
A tag with a literal resolver name in it, like titel Misspelled resolver name — unknown names become literal text. data-toggl-debug-issues names it
Two tags where you wanted one field:value tag Literal parts under tags; use regexreplace instead
Every button on a list shows the same value root where the resolver should use closest scoped to the row
A fallback chain never reaches its second option The first option is a typo that resolved to literal text
No buttons at all, anywhere on the site A malformed anchor selector — it takes down the whole integration, not one button
Project chip empty but tags fine Projects set to Ignore in the Page data controls, or the name does not match exactly — check capitalisation and stray whitespace