Skip to content

Home  /  Docs  /  For an assistant

Workspace specification, for an assistant

The same subject as the workspace guide, written as a contract rather than an explanation. One button puts all of it on your clipboard, with slots at the top for two or three real addresses from your project. Paste, fill them in, send.

A request with slots for your addresses, then everything below it as plain text. It is one file, /ai-prompt.txt, so a tool can fetch it instead of a person copying it.

Everything below was checked against the shipped code. Where the code does something surprising, the surprise is recorded rather than smoothed over — an assistant that does not know about it will produce workspaces that fail in ways the author cannot explain.

What to return

When you have enough information, reply with the workspace in one fenced json code block and nothing else: no prose before or after it, no headings, no trailing summary. When there is more than one workspace, one block each, with nothing but the workspace's name on the line before it.

The fence is not optional. Unfenced JSON is rendered as Markdown on its way to the author's screen, and Markdown eats the parts of a workspace that matter most: [a-z]{2} in a character class comes out as {2}, a doubled backslash comes out single, and * disappears. What arrives then looks like what you sent and is not. Inside a fence nothing is interpreted.

One block holds one workspace: a JSON object, not an array and not a wrapper. The settings page edits one workspace at a time, and the author copies the block's contents — not the fence — into that editor, which runs JSON.parse over exactly what is pasted.

JSON has no comments. The annotated blocks below use them to describe the schema; never emit one. Double quotes on every key and string value, no trailing commas.

Diagnosing is the exception. When the author brings a workspace and a symptom rather than addresses, answer in prose, and put nothing in a fence but the part that changes. See when a workspace does not behave.

When you do not have enough information, do the opposite: ask, and emit no JSON at all. A guessed workspace is worse than a question, because it looks finished and fails later on one page in one environment. See what to ask for.

Output shape

One JSON object. One object is one project.

{
  "workspaceName": "string, required, shown in the sidebar",
  "schemaVersion": 2,                 // integer, optional, emit 2
  "languages": ["de-DE", "en-global"],// string[], optional, emit whenever
                                      // the project uses locales
  "variables": [ /* VariableConfig, optional */ ],
  "environments": [                   // required, at least one
    {
      "name": "string, required, the group heading in the popup",
      "onlyOn": "regex, optional",    // build this group only on addresses it matches
      "links": [                      // required
        {
          "name": "string, required, the link text",
          "url": "string, required, a template",
          "onlyOn": "regex, optional",// same field, one link
          "defaultLanguage": "string, optional",
          "defaultPath": "string, optional"
        }
      ]
    }
  ]
}

ticketPrefix is accepted on input and converted away. Never emit it.

VariableConfig

{
  "name": "string, required, [A-Za-z0-9_-]+ only",
  "label": "string, optional, shown on the input and the chip",
  "extractFrom": [ /* VariableSource, optional */ ],
  "promptIfMissing": true,   // boolean, optional. Offer a text box when unresolved
  "remember": true,          // boolean, optional. Keep the last value found, per ticket
  "valuePattern": "\\d+"     // string, optional, default \d+. Shape the value takes inside a template.
                             // Widen it for anything that is not digits
}

VariableSource

Exactly one of urlPattern or domPattern. Both present is rejected, neither is rejected.

Field Applies to Meaning
urlPattern address Regex run against the active tab address. Capture group 1 is the value.
domPattern page Regex run against every link href on the page. Capture group 1 is the value.
scopeSelector page CSS selector. Limits the search to matching elements. Must be a valid selector.
hoverSelector page CSS selector inside each scope. Pointer events are sent to these before reading, then released.
requireInScope page Regex. A scope is trusted only if its markup also matches. Supports {{ticketNumber}}.
waitMs page Integer 0 to 15000, default 5000. How long to keep re-reading after hovering.

All regexes are case insensitive. In JSON, a backslash doubles: \d is written \\d.

Placeholders

{{language}} and {{path}} are reserved and filled from the address. Every other {{name}} refers to a declared variable. A placeholder naming nothing declared is left in the output verbatim, so a typo produces a broken URL rather than an error.

Resolution order

For one tab address and one workspace list:

  1. Match an environment. Walk workspaces, then environments, then links in declaration order. Take each link's hostname, including port. Match when the page's hostname contains it as a substring. The first match wins and ends the search. The matched workspace is the only one used.
  2. Detect the locale. demo-xx-xx first, then xx, xx-xx, xx-global. Empty when nothing matches.
  3. Extract the path. Within the matched environment, find the link whose expanded base address is the longest prefix of the page address. Remove that prefix, then the locale segment. Query strings and fragments stay part of the path.
  4. Resolve variables, strongest source first:
    1. values captured while matching a host template in step 1
    2. urlPattern sources run against the address
    3. values handed in by the caller: what the user typed, what was read off the page, what was remembered. Each is first passed through that variable's own urlPattern sources, so a pasted URL or a prefixed number reduces to the value. Only declared names are accepted.
  5. Build links. For every link in every environment of the matched workspace, not only the matched environment.

ticketNumber is an ordinary variable with no special handling.

One workspace, and how its groups stay apart

One project is one workspace, however many hosts it has: every environment, every localhost, the API hosts, the media host, the ticket system, the action links. Default to that. The popup groups links by environment name, so a workspace with six environments reads as six labelled groups rather than a list. Emit a second workspace for a second project, not for a second kind of host.

What makes that safe is a rule to design against: every link in the matched workspace is built, whether or not it fits the page. Put a media host in beside a website on nothing but {{path}} and every page offers two dead media addresses:

on  https://www.example.com/de-de/products/pump-42
     https://media.example.com/products/pump-42        ← nothing is there
     https://media-stage.example.com/products/pump-42  ← nor here

Say where a group applies with onlyOn, a regex tested against the whole address. It sits on an environment, on a single link, or on both. A group whose pattern does not match the current address contributes no links at all:

"environments": [
  { "name": "Website",
    "onlyOn": "^https?://(?:www|www-stage)\\.example\\.com/",
    "links": [
      { "name": "Live",  "url": "https://www.example.com/{{language}}/{{path}}" },
      { "name": "Stage", "url": "https://www-stage.example.com/{{language}}/{{path}}" }
    ] },
  { "name": "Media",
    "onlyOn": "^https?://media(?:-stage)?\\.example\\.com/",
    "links": [
      { "name": "Live",  "url": "https://media.example.com/{{path}}" },
      { "name": "Stage", "url": "https://media-stage.example.com/{{path}}" }
    ] }
]

On a page the media group is absent, on an image the website group is, and both live in one workspace with {{path}} intact in every template. Absent onlyOn means everywhere, which is what every link without it says. Emit it for any family of hosts whose addresses have a different path shape from the rest of the project.

Two properties of it matter before you design around it. It gates building, not matching. An environment that is gated off can still be the one the address matched, and {{path}} still comes out of it, so gating a group never changes which environment a page resolves to. And the pattern needs no capture group, unlike every other regex in this format: it answers yes or no, so there is nothing to read out of it.

Do not gate with a variable that fails to resolve. Giving a group a variable extracted only from its own hosts has the same visible effect, since a link referring to a variable with no value is dropped, but it costs those links their {{path}} and their defaultPath to buy nothing. onlyOn says it directly.

A variable is still the answer when a link needs a piece of the address rather than the whole path. That is reshaping rather than gating, and it carries a value instead of a condition. Given https://www.example.com/de-de/products/pump-42 and an API at https://api.example.com/products/de/pump-42:

"variables": [
  { "name": "slug",   "extractFrom": [{ "urlPattern": "/([^/?#]+)/?$" }],          "valuePattern": "[^/?#]+" },
  { "name": "region", "extractFrom": [{ "urlPattern": "/([a-z]{2})-[a-z]{2}(/|$)" }], "valuePattern": "[a-z]{2}" }
]

with the link https://api.example.com/products/{{region}}/{{slug}}, which no longer needs {{path}} at all. Cut such a pattern to what the value is, not to where the link belongs: /(\\d+-[^/?#]+)$ reads a job slug and reads nothing on the listing above it, so the link is held back there as a side effect of the value being absent. Where the link applies is onlyOn's job, and saying it there leaves the reason legible in the diagnostics.

valuePattern has to be widened past its \\d+ default for any value that is not digits. It is the shape the value takes inside a template, so a slug left on the default makes the extension look for a number in that position later and find nothing.

A ticket system or a repository is a link in the workspace it serves. It is also what makes a ticket page somewhere ENV Portal can offer links from at all.

Invariants an assistant must not violate

Validation, as enforced on save

Rule Effect when broken
Unknown key on a link, environment or variable rejected
Unknown key at the top level of a workspace accepted and preserved
name and url on a link both required
Variable name outside [A-Za-z0-9_-]+ rejected
Source with both urlPattern and domPattern rejected
Source with neither rejected
Pattern that does not compile rejected
Pattern with no capture group, including only (?:…) rejected
scopeSelector that is not a valid CSS selector rejected
waitMs outside 0 to 15000, or not an integer rejected
A variable with no extractFrom at all accepted, resolved by typing alone
onlyOn that does not compile rejected
onlyOn with no capture group accepted. It answers yes or no about an address, so there is nothing to capture

Behaviour that will surprise you

Four of these. Ignoring them produces workspaces that look right and misbehave.

Locale detection guesses, unless the workspace declares languages. The guess has two holes, and both are silent. It is greedy, so any two-letter segment counts: /ui/settings yields language: "ui" and path: "settings". It is also case-sensitive and lowercase-only, so the canonical BCP 47 spelling de-DE is missed completely — the locale drops out of every link built from that tab, and nothing reports an error.

Declaring the locales the project uses turns the guess into a lookup, matched case-insensitively and longest-first, which closes both holes:

"languages": ["de-DE", "en-GB", "en-global"]

Emit languages whenever the addresses show a locale segment. Take the spellings from the real addresses. If a project uses two-letter segments that are not locales, leave {{language}} out of those templates instead.

The locale and path are appended to a bare origin, and to nothing else. https://dev.example.com becomes https://dev.example.com/de-de/products/pump-42, which is the rule's whole purpose. A placeholder-free template that carries a path, a query or a fragment is an action rather than a page — a cache-clear endpoint, a webhook, a health check — and is emitted exactly as written, including https://dev.example.com/_node/clear-cache?force=1. So write an action link as it must be called, and never expect a suffix to finish one. A template holding any placeholder, a variable included, is also left alone.

One hostname must not nest inside another. If dev is dev.example.com and a branch deployment is mr-3282.dev.example.com, the second contains the first, so on the branch deployment the first match is the dev environment. Path extraction then runs against the wrong environment and returns empty, and every link loses the page. Give branch deployments a distinct host such as mr-3282.branch.example.com. Matching is first-wins, not closest-wins.

A page is only reachable if some link names its host. To offer links from a ticket page, the workspace needs a link pointing at the ticket system.

What to ask for before writing one

The template is derived from real addresses, never guessed from a description. That only works if the addresses carry the structure, so the first job is deciding whether they do.

An example is a shape, not a page

Addresses are pasted to show structure. Two of them describing different pages still describe one template, so https://api.example.com/jobs/de/9412-marketing-lead is full evidence for https://api.example.com/jobs/{{lang}}/{{slug}} even when the website address the author pasted beside it is job 9544. Never ask which page an example belongs to, and never ask for a matching pair. Read the shape and move on.

What that leaves is one real decision, and it is made in the pattern rather than asked about: where a link makes sense on some pages only, give its variable a urlPattern narrow enough to find nothing elsewhere. A job endpoint keyed on /(\\d+-[^/?#]+)$ resolves on a job page and is held back on the listing above it, which is the behaviour wanted. The same variable keyed on /([^/?#]+)$ would resolve everywhere and build a call to a job that does not exist.

An origin is not a page

Two different things arrive looking alike:

Given What it tells you
https://www.example.com/de-DE/products/pump-42 A page. Locale segment, path shape, depth — everything the template needs.
https://www.example.com An origin. The host, and nothing else. It cannot tell you whether the project is /{{language}}/{{path}}, /{{path}}, /{{language}}/products/{{path}}, or something else again.

Deriving a locale or a path template from an origin means inventing the project's URL structure. Do not. Note also that a bare origin as a link url is a placeholder-free template, which triggers the append behaviour described above.

Stop and ask when any of these is true

Do not ask about anything already settled: whether it is one workspace or several, whether two addresses in different locales are the same page, or something the author said does not exist. At most three questions, in one message, and only for what cannot be derived. A fifth question costs more than a workspace the author checks with ?debug=1.

Ask the minimum needed and stop there. Three addresses of one page are usually the whole answer:

https://www.example.com/de-DE/products/pump-42
https://www-stage.example.com/de-DE/products/pump-42
http://localhost:4321/de-DE/products/pump-42

Also worth asking, once the addresses are in

  1. One real ticket address, and the ticket key shape, for example PROJ-42395.
  2. Whether branch or preview deployments exist, and one real address for one.
  3. Which locale to assume when an address has none, for defaultLanguage. Never invent one.
  4. For every family of hosts beyond the website, whether its addresses carry a locale and share the website's path shape. That is what decides its onlyOn, and one address per family answers it without asking.

Derive the shared prefix and the varying parts from the addresses rather than asking the author to describe them.

Worked example

Given https://www.example.com/de-de/products/pump-42, https://dev.example.com/de-de/products/pump-42, https://mr-3282.branch.example.com/de-de/products/pump-42, https://gitlab.example.com/team/repo/-/merge_requests/3282 and https://example.atlassian.net/browse/PROJ-42395:

{
  "workspaceName": "Example",
  "schemaVersion": 2,
  "variables": [
    {
      "name": "ticketNumber",
      "label": "Ticket number",
      "extractFrom": [{ "urlPattern": "PROJ-(\\d+)" }],
      "promptIfMissing": true
    },
    {
      "name": "mrNumber",
      "label": "Merge request number",
      "extractFrom": [
        { "urlPattern": "/-/merge_requests/(\\d+)" },
        { "urlPattern": "^https?://mr-(\\d+)\\." },
        {
          "domPattern": "/-/merge_requests/(\\d+)",
          "scopeSelector": "[data-testid*='development-context-panel']",
          "hoverSelector": "button",
          "requireInScope": "PROJ-{{ticketNumber}}"
        }
      ],
      "remember": true,
      "promptIfMissing": true
    }
  ],
  "environments": [
    {
      "name": "Live",
      "links": [
        { "name": "Live", "url": "https://www.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" }
      ]
    },
    {
      "name": "Dev",
      "links": [
        { "name": "Dev", "url": "https://dev.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" }
      ]
    },
    {
      "name": "Branch",
      "links": [
        { "name": "Branch deployment", "url": "https://mr-{{mrNumber}}.branch.example.com/{{language}}/{{path}}", "defaultLanguage": "de-de" },
        { "name": "Merge request", "url": "https://gitlab.example.com/team/repo/-/merge_requests/{{mrNumber}}" },
        { "name": "Ticket", "url": "https://example.atlassian.net/browse/PROJ-{{ticketNumber}}" }
      ]
    }
  ]
}

Verified behaviour of that workspace:

On this page Environment Path Branch link Ticket link
www.example.com/de-de/products/pump-42 Live products/pump-42 held back held back
example.atlassian.net/browse/PROJ-42395 Branch empty built, once the page yields the number built
mr-3282.branch.example.com/de-de/products/pump-42 Branch products/pump-42 built held back

The held-back links are correct rather than missing. On the live site neither number is present, and on the branch deployment no ticket number appears in the address.

Before you reply

Run this over the JSON you are about to send.

When a workspace does not behave

A workspace that saves and then behaves wrongly has one cause, in one of the five steps of the resolution order above, and the address plus the Try an address card names which. Ask for both before reading anything else: the exact address the author was on, and what that card says on it. Then change one thing and have them run it again. Do not rewrite the workspace. A rewrite hides which line was wrong, and the next symptom starts from nothing.

What the author sees What it is The fix
The popup offers nothing at all No link's hostname is a substring of the page's hostname, so no environment matched Add a link naming that host. A page is reachable only if some link names it
Links come from the wrong environment One host nests inside another and the first declaration won Give the inner host a distinct name, or declare it first. Matching is first-wins, never closest-wins
Every link lands on the site root The path came out empty: no link in the matched environment prefixes the address, often because the template spells the locale differently or carries a path the address does not Compare the template against the address the card shows, one segment at a time
The locale is gone from every built link No languages, and the guess is lowercase-only, so de-DE was missed Declare languages with the spellings the addresses use
The locale is a segment that is not a locale, such as ui The guess is greedy about any two-letter segment Declare languages, or leave {{language}} out of those templates
One link is missing, the rest are fine It refers to a variable with no value on that address, or its own onlyOn did not match The card names which. Widen the variable's source, or the pattern
A whole group is missing The environment's onlyOn did not match Test the pattern against the address as written, scheme included
A link appears on a page where it is dead It has no onlyOn, so it was built with a path that belongs to another family of hosts Give its environment an onlyOn
An action link came out with a locale and path stuck on It is a bare origin, which is the one shape that gets the suffix Write the endpoint's own path into the template. Anything past the host stops the append
A value read off the page never arrives Nothing reads a page except the click that opens the popup, and two distinct values on one page are discarded Narrow scopeSelector and requireInScope until one page yields one value. The Try an address card cannot resolve these at all
A variable resolves, and the link using it still does not build The name in the template does not match the declared name. An undeclared placeholder passes through as written Compare the two spellings, including case
A host template with a variable in it never matches valuePattern defaults to \d+ and the value is not digits Declare the shape the value takes

A workspace the settings page refuses to save is a different failure, and the message names the field. The rejections are listed under validation above: an unknown key inside a link, an environment or a variable, a source with both urlPattern and domPattern or with neither, a pattern that does not compile or captures nothing, an onlyOn that does not compile, a variable name outside [A-Za-z0-9_-]+, and a waitMs outside 0 to 15000. Unknown keys at the top level of a workspace are kept, so a typo there fails quietly instead.

Checking your output

The author can check a workspace without leaving the settings page. The editor's sidebar has a Try an address card: they paste one page address into it and it shows the matched environment, the locale, the path, every resolved value, every link it would open and every link it would not, with the reason. It reads the editor buffer, so it answers before anything is saved. Tell them to try one address per family of hosts, and ask for what it says rather than guessing.

Two limits on that card, both worth passing on. It tries only the workspace being edited, while the popup checks every saved workspace and takes the first whose host matches. And it reads no page, so a link waiting on a domPattern value shows as held back there and still works in the popup.

The popup itself answers the same question on a real page: opening the settings page with ?debug=1 makes it list every resolved value with its source, and every held-back link with what it waits for.

← The same rules, written for a person