Force CockpitA VSCode cockpit for Salesforce orgs, built around your own automation. Connect via the SF CLI, then write Apex, shell, JavaScript, or AI-powered scripts — organized into folders and categories — to automate whatever your workflow needs, plus SOQL querying, anonymous Apex, REST calls, live monitoring dashboards, and AI-explained debug logs, all without leaving VSCode. Contact: Pablo Fernández Posadas @paferpo InstallationFrom the VS Code Marketplace
From a
|
| Tab | Description |
|---|---|
| Overview | Org info card, storage usage bars (Data Storage and File Storage), and an Ask the AI multi-turn chat card (with saved conversation history) for ad-hoc questions |
| Scripts | Your own YAML-defined scripts (Apex, shell, JS, AI-assisted, REST), organized into folders — plus two built-in utilities (Clone User, Reactivate OmniScript) |
| SOQL | SOQL query editor (keyword highlighting, tabs, history, autocomplete, a browsable fields side panel, Tooling toggle, explained errors) with a filterable, sortable results table that can be charted and pinned to Monitoring in two clicks, plus a Record Detail to view and edit every field of a record, one record per tab |
| Apex | Execute anonymous Apex in tabs, with recent/saved snippets, a log-level picker, and the debug log parsed the same way as the Debug Logs tab — limits, detected issues, execution tree and queries |
| Monitoring | SOQL-powered Chart.js dashboards loaded from YAML config files |
| REST | Call any REST API or Apex REST endpoint on the connected org, with request tabs, custom headers, request history/saved requests, and a color-coded status + headers + clickable-record-Id response |
| Debug Logs | Set trace flags on any user (including the Automated Process user), then read the resulting Apex logs: filtered by category, summarised against the governor limits, with detected issues, an execution tree, a rated query-plan table, a search across every log's content, and AI analysis |
| Plugins | Your own panels. Drop a folder into force-cockpit/plugins/ with some HTML and JavaScript and it becomes a tab of its own, with the org connection already wired up |
Overview Tab
The Overview tab shows org connection details and storage usage bars (Data Storage and File Storage).
Ask the AI
Once connected, the Overview tab also shows an Ask the AI card for ad-hoc questions — no need to author a YAML script first. It uses the same Language Model API (GitHub Copilot) as AI scripts and the Debug Logs analysis panel, but as a multi-turn conversation: follow-up questions build on everything you already asked, including the results of any tool the model already ran.
Requirements: GitHub Copilot must be enabled in VS Code, and an org must be connected (the card is hidden otherwise).
Two read-only toggles control what the model can do this conversation:
- Read workspace files — search and read workspace source/metadata (Apex, objects, fields, flows, LWC, permission sets…), same as AI scripts'
allow-read-workspace-files. - Query the org — look up object schema (
describe_object) and run SELECT-only SOQL, including against the Tooling API when needed (e.g.FieldDefinition,EntityDefinition,ApexClass) — the model picks which API a query needs on its own. It also knows which user you're connected as (no extra round-trip), so it can answer "do I have access to this field" instead of just describing the schema in the abstract. It can never write or modify data.
[!NOTE] These two toggles are locked once you ask your first question — checking or unchecking them mid-conversation has no effect until you start a New chat. This keeps the model's declared tools consistent with what it already used earlier in the thread.
Your workspace's Agent Skills are always available with no picker — the model sees the same short id + description catalogue AI scripts do and can pull a skill's full content on demand via read_skill. There's nothing to select: since the question changes every time, the model decides which skill (if any) is relevant.
Use New chat to clear the conversation and unlock the toggles again. Open as markdown renders the whole conversation in VS Code's Markdown preview; Copy copies it as Markdown text. Switching orgs (or disconnecting) automatically starts a fresh conversation, since prior answers may reference org data from the previous connection.
History ▾ lists your past conversations — one shared list, not scoped to any particular org. Every conversation is saved as you go: the moment each reply finishes, it's written to History under one entry for the whole conversation, so a question followed by three follow-ups shows up as a single row, not four — closing the panel, switching orgs, or the extension reloading never loses anything you've already seen an answer to. Click a row to reopen it (its transcript, model, and locked tool-access settings are all restored) and continue asking follow-ups — they keep updating that same entry — or click × to delete it. A very old or unusually large conversation may only restore its transcript for reading, without supporting true follow-up continuation — a small note appears in that case.
Scripts Tab — YAML Scripts

Scripts can also be created and edited directly in the UI — no need to write YAML by hand.

[!TIP] The code field in the new/edit form is a simple textarea. For comfortable editing — syntax highlighting, multi-cursor, find & replace — click ✎ Open in editor to edit the code body in a real VS Code editor tab. Saving there (Ctrl/Cmd+S) syncs the code straight back into the form (the form stays open); nothing is written to disk and your other fields are untouched. Click the form's Save to persist the whole script.
[!TIP] Need a near-duplicate of a script? Open it for editing and click Clone — the form is pre-filled with every field of the original and
_copyis added to the name. Tweak whatever you need and click Save to create the copy. Nothing is written until you Save, so the original stays untouched if you cancel.
[!TIP] Prefer hand-editing the raw YAML? When editing an existing script, click 📄 Open YAML to open its underlying
.yamlfile in a VS Code editor tab. The edit form closes (you've switched to raw editing, so there's no risk of a stale form Save overwriting your changes), and the script list refreshes automatically when you save the file. (The button only appears when editing an existing script.)
The Custom sub-tab executes scripts defined in YAML files. Five script types are supported (Apex, Command, JavaScript, AI — see AI scripts — and REST — see REST scripts). Scripts live under force-cockpit/scripts/{category}/*.yaml (shared) or force-cockpit/private/scripts/{category}/*.yaml (private, git-ignored). Sub-categories are also supported: {category}/{sub-category}/*.yaml gives a second row of pills for drilling down.
[!TIP] Repository examples: Ready-to-use YAML script examples are available under
force-cockpit/scripts/examples/. They live in this repo rather than inside the extension — copy the ones you want into your ownforce-cockpit/scripts/folder.
[!TIP] Writing scripts with an AI assistant? This repo ships an Agent Skill documenting the full script schema — every field, the placeholder escaping rules, the JS sandbox API and the chaining model. Copy the
force-cockpit-yaml-scripts/folder into your workspace's.claude/skills/(or.github/skills/) and your assistant will author valid scripts first time. That location is also where Force Cockpit's own AI scripts look for skills, so the same copy shows up in the Skills picker.
# Apex script — requires org connection
name: My Apex Script
description: What this script does.
apex: |
System.debug('Hello from Apex');
# Terminal command — no org connection required
name: My Command
description: Runs a local shell command.
command: npm run build
# JavaScript script — runs in Node.js VM sandbox, org connection is optional
name: My JS Script
description: Query Salesforce with jsforce.
js: |
const result = await query("SELECT Id, Name FROM Account LIMIT 5");
log(JSON.stringify(result.records, null, 2));
# REST script — one REST / Apex REST call against the connected org
name: Create Account
description: Creates an Account through the REST API.
rest:
method: POST
endpoint: /services/data/v65.0/sobjects/Account
body: |
{ "Name": "Acme" }
Exactly one of apex:, command:, js:, ai:, or rest: is required. Click Execute on any script card to run it.
Configurable Inputs
Scripts can declare input variables that are prompted at execution time. Add an inputs: section to your YAML:
name: Update Order Status
description: Updates an order and its line items.
inputs:
- name: orderId
label: Order ID
required: true
- name: status
label: Status
type: picklist
required: true
options:
- New
- Submitted
- Completed
- Cancelled
- In Progress
apex: |
Id orderId = '${orderId}';
// ... use orderId and status in your Apex code
Each input supports:
| Field | Required | Description |
|-------|----------|-------------|
| name | Yes | Variable identifier (alphanumeric + underscore) — used as ${name} in the script body |
| label | No | Display label (defaults to name) |
| type | No | string (text input, default), picklist (dropdown), checkbox, textarea (multi-line) or file (path field + Browse button) |
| required | No | If true, Execute is disabled until the field is filled |
| options | Picklist only | List of selectable values |
| default | Checkbox only | true to pre-check the box |
Write ${variableName} in your script code where you want the value substituted. Escaping is handled automatically (Apex-safe for apex, JSON-safe for js, raw for command).
A file input lets the user pick a file with the OS dialog (or paste a path). ${name} receives the file's absolute path, not its contents. A js script reads it with fs.readFileSync("${csvPath}", 'utf8'). In a command script, quote it (cat "${csvPath}") so paths with spaces stay one argument.
System Placeholders
In addition to user-defined inputs, scripts can use built-in system placeholders that are automatically resolved from the connected org:
| Placeholder | Description |
|---|---|
${orgUsername} |
Salesforce username (not alias) of the connected org |
System placeholders use the same ${name} syntax and type-appropriate escaping as user inputs. If no org is connected, they resolve to an empty string. If a user-defined input has the same name as a system placeholder, the user input takes precedence.
name: Show My User
apex: |
System.debug('Running as: ${orgUsername}');
| Type | Badge | Org required | Output |
|---|---|---|---|
| Apex | Blue | Yes | Debug log (USER_DEBUG filter available) |
| Command | Purple | No | stdout/stderr |
| JavaScript | Green | No | log() / console.log() output |
| AI | Orange | Yes | Streamed model analysis |
JS script context: connection (jsforce Connection or null), org (OrgDetails or null), query(soql), log(), error(), console, fs, path, yaml, plus runScript() / setOutput() for composing scripts.
Apex scripts run at a quiet, fixed log level (Apex Code: DEBUG, System: ERROR, everything else NONE) — the log holds just your own System.debug() output plus any unhandled exception, nothing else. This level is independent of anything configured in the Debug Logs tab: Salesforce always honors the log level a script execution explicitly requests over an org-wide trace flag, so a Debug Logs preset has no effect on a yaml-script's own log.
Composing scripts
Shared logic can live in one script and be reused by others instead of being copy-pasted. There are two ways to chain, and they interoperate:
then: — a declarative list, available on every script type. The steps run in order once the script's own body succeeds, so an Apex script keeps all its Apex and hands off when it's done:
name: 🛖 Create Account Hierarchy
inputs:
- name: accountName
label: Account Name
required: true
- name: cartType
label: Enterprise cart
type: picklist
options: [None, Quote, Order]
apex-file: force-cockpit/scripts/testData/accHierarchy.apex
then:
- script: testData/create-enterprise-cart
when: ${cartType} !== "None" # skip the step entirely for None
with:
accountId: ${contractantAccId} # published by the Apex above
cartType: ${cartType} # this script's own input
namePrefix: ${accountName}
A step may carry a when: guard. Both outcomes are logged with the substituted expression beside the original (when: ${cartType} !== "None" → "Quote" !== "None"), so a guard that fires the wrong way shows its own reason — → "" !== "None" means the value never reached the condition. A when: is a JavaScript expression — &&, ||, !, parentheses, ternaries and string methods all work, so when: ${cartType} !== "None" && ${status} === "Active" does what it looks like. Placeholders are substituted as literals (a checkbox becomes a real boolean, so a bare ${flag} reads as "if ticked"), which means values can never inject code, and comparands must be quoted: !== "None", not !== None. A syntax error or an unquoted comparand marks the script invalid when it loads, before anything runs. The expression is evaluated in a sandbox with no require, no process and a 100 ms timeout.
runScript(id, inputs?, options?) — imperative calls inside a js script, for when the chain needs conditionals, loops or error handling. It resolves with the callee's result and rejects if the callee fails (pass { throwOnError: false } to handle that yourself).
name: 🔁 Bulk Create Contacts
inputs:
- name: accountId
required: true
- name: lastNames
type: textarea
required: true
js: |
const accountId = "${accountId}";
const names = input.parseLines("${lastNames}", ['lastName']).map((r) => r.lastName);
for (const lastName of names) {
const result = await runScript(
'examples/create-contact-for-account',
{ accountId, lastName },
{ throwOnError: false }, // one bad row must not lose the rest
);
log(result.success ? 'created ' + result.outputs.contactId : 'failed: ' + result.message);
}
A loop, a value read back and per-item error handling — none of which then: can express. Both this and the then: example above live in the repo under force-cockpit/scripts/examples/ — copy them into your own force-cockpit/scripts/ folder to try them.
Because every call is a separate transaction, a script hands values on by publishing them — and any kind can hand a value to any other kind:
| Kind | How it publishes a value |
|---|---|
| Apex | System.debug('::fc-output accountId=' + acc.Id); |
| Command | echo "::fc-output buildId=$BUILD_ID" on stdout |
| JavaScript | setOutput('target', 'staging') — not a printed line |
A caller reads them as result.outputs in JS, or interpolates them as ${name} in a then: step's with. Nothing is inherited — a called script sees only what with: hands it, so a value needed several scripts down must be forwarded at each hop. Values are always strings, and escaping is handled for the receiving script's type — pass raw values, never pre-escape. A name nothing published resolves to empty rather than to the literal text ${name}, so a required: input on the callee reports it cleanly.
[!WARNING] In a JavaScript script,
log('::fc-output foo=bar')does nothing — a JS script's log also contains the logs of everything it called, so it is never scraped for markers. UsesetOutput(). Note also that command scripts receive values unescaped, since a shell has no safe universal quoting.
Values flow forwards only: with: feeds the caller's outputs into the step it starts, and a caller can never read what its then: step produced — publishing outputs from the last script in a chain is dead code. Use runScript() when you need a result back.
Chains nest (a script reached by then: runs its own then: too) and run depth-first, so for a root with steps x and y where x chains to x1, the order is root → x → x1 → y. The callee's output streams into the caller's log under a ── ▶ {id} ── header, so the whole run is visible in one card. Cancelling cancels the chain. Circular calls are rejected and nesting is capped at 10 levels. A js script only reports the outputs it sets itself — re-export a callee's value with setOutput to pass it further up.
[!NOTE] The script form has no editor for
then:; author it in the YAML via 📄 Open YAML. Saving from the form preserves it.
[!NOTE] On a production org or a protected sandbox, every click of Execute prompts for confirmation — nothing is remembered between runs. The prompt you answer covers the whole chain: the scripts it goes on to call do not each add a prompt of their own.
AI scripts
An AI script optionally gathers Salesforce data with a fixed, author-defined step and then uses a language model (via VS Code's built-in Language Model API, powered by GitHub Copilot) to analyse it. The analysis streams into the script's output. The gather step is optional — omit it (uncheck "Gather data first" in the form) for a script driven purely by its prompt + inputs.
Requirements: GitHub Copilot must be enabled in VS Code (the first run shows a one-time consent prompt), and an org must be connected.
name: Energy account analysis
description: Summarises energy-industry accounts.
model: auto # the chosen model's id — the form requires one and defaults to Copilot's "Auto"
inputs:
- name: industry
label: Industry
required: true
gather: # OPTIONAL fixed data step — exactly one of soql / apex / apex-file (omit for a prompt-only script)
soql: SELECT Id, Name, AnnualRevenue FROM Account WHERE Industry = '${industry}'
ai: | # the analysis prompt (use ai-file: to load it from a file)
Summarise the accounts below and flag anything unusual about their revenue.
allow-followup-queries: true # optional — lets the model run follow-up SOQL for extra context
allow-read-workspace-files: true # optional — lets the model search & read workspace files (any non-gitignored source/metadata)
skills: # optional — ids of skills the model may read on demand
- data-quality-checklist
How it works and why it's safe:
- You control the data step. The
gatherSOQL/Apex is yours and runs exactly as written — the model never writes or chooses Apex, so there is no risk of it modifying data. - The model only analyses. It receives the gathered data + your prompt and replies with text.
- Optional follow-up queries. With
allow-followup-queries: true, the model may run additional SOQL queries (SELECTonly) to pull more context — against the Standard API for everyday business data, or the Tooling API for metadata objects (FieldDefinition,EntityDefinition,ApexClass,TraceFlag…) when the model determines it needs to. It can never run anything that writes. - Optional workspace file access. With
allow-read-workspace-files: true, the model can search workspace files by name (a case-insensitive regular expression — a plain word likeSelectormatchesOrderSelector,AccountSelector) and read any matching source/metadata file (Apex, objects, fields, flows, LWC, permission sets…). Handy for diagnosing stack traces across your metadata. Anything excluded by your.gitignore(e.g.force-cockpit/private/) is never listed or read. - Model picker. Picking a model is required (the field is marked with a red
*). The list is populated from the models Copilot offers — de-duplicated and sorted alphabetically — and defaults to Copilot's "Auto" model when it's available. If a script's saved model is no longer available at run time, Force Cockpit falls back to Auto (or the first available model), prepends a warning to the output, and shows a notification — so the run still completes. Note: some models don't support follow-up queries — gather + analyse still works regardless. - Skills (reusable playbooks). Tick Skills in the form to attach Agent Skills — markdown guides stored as
{skill-id}/SKILL.mdunder.claude/skillsor.github/skillsin your workspace. The model sees a short catalogue (id + description) of the attached skills and can pull a skill's full content on demand via a tool; nothing is auto-injected. Override the scanned folders withskillsPathsinforce-cockpit/config.yaml. - Schema is cached locally. Before querying, the model checks object fields via a
describe_objecttool. Results are cached per workspace underforce-cockpit/.describe-cache/(git-ignored, 2-week expiry) and shared with the SOQL tab's autocomplete, so repeated lookups don't hit the org. Click the 🔄 refresh button next to the connection status to clear the cache and re-pull the latest schema. - Knows who it's connected as. The model always has access to your connected username (no round-trip needed) — since
describe_object's field list already reflects that user's field-level security, this lets it answer "can I see this field" directly instead of describing schema in the abstract. - Open as markdown. AI analysis is written in Markdown. Once a run finishes, an Open as markdown button (next to Open in editor / Copy to clipboard) opens the output in VSCode's built-in Markdown preview — headings, lists, tables, and code blocks rendered nicely. Nothing is written to disk; it opens an in-memory untitled document. The gathered data is shown as a code block in the preview.
${input} and ${orgUsername} placeholders work in both the prompt and the gather step.
REST scripts
A rest: script fires one REST / Apex REST request against the connected org — the same thing the REST Tab does interactively, but saved, parameterised and chainable. Requires a connected org.
name: Deactivate a user
description: Sets IsActive = false on a user.
inputs:
- name: userId
label: User Id
required: true
rest:
method: PATCH # optional; GET | POST | PUT | PATCH | DELETE (default GET)
endpoint: /services/data/v65.0/sobjects/User/${userId}
headers: # optional
Sforce-Auto-Assign: 'FALSE'
body: | # optional; or `body-file: path/from/workspace/root.json`
{ "IsActive": false }
The response is the output. The log shows the request line, the status code, the response headers and the pretty-printed body (rendered as a table by default — untick Format JSON for raw).
A non-2xx response fails the script. Unlike the REST tab, which renders a 404 as an ordinary result, a script treats it as a failure so a
then:chain stops instead of passing a bad response to the next step. Salesforce's own error message anderrorCodeare shown.Chaining works out of the box. A rest script publishes
${status}plus every top-level value of a JSON response body — so Salesforce's{"id": "001…", "success": true}gives the next script${id}with no parsing step:rest: method: POST endpoint: /services/data/v65.0/sobjects/Account body: '{ "Name": "${accountName}" }' then: - script: examples/create-contact-for-account with: accountId: ${id}Nested objects and lists are skipped (a chained value is always a string), and
${status}always means the HTTP status even if the body has a field of that name.Placeholders work in the endpoint, the header values and the body. Body values are JSON-escaped, so an apostrophe or a quote in an org value can't break the payload. Endpoint values are inserted as-is, without URL-encoding — the same as typing them in the REST tab — so if a value may contain
&,?or a space, encode it yourself.The body must be a string — a
|block, or quoted as in the example below. Inline JSON (body: {"Name": "Acme"}) is a YAML mapping, not a string, and the script is rejected as invalid rather than silently sending an empty body.Absolute URLs are allowed (
https://…), matching the REST tab. Be aware that your org's access token is sent as aBearerheader on those requests too, so only point a script at a host you trust.Cancellable. The ✕ Cancel button aborts the request in flight, not just its reply.
[!TIP] Need a loop, a condition, or several requests in one run? Use a
js:script instead and callrestCall(method, endpoint, body?, headers?)— the body may be a string or an object (serialized to JSON for you). It returns{ status, statusText, headers, body }and, unlike the raw jsforceconnection.request(), does not throw on a 4xx/5xx, so you can branch on the status directly:js: | const r = await restCall('GET', `/services/data/v65.0/sobjects/Account/${accountId}`); if (r.status === 404) { log('No such account'); return; } log(r.body.Name);
Private scripts
Check Private when creating or editing a script to save it to force-cockpit/private/scripts/ instead of the shared folder. The extension automatically adds force-cockpit/private/ to .gitignore on startup. Private scripts show a 🔒 badge and can be filtered with the All / Shared / Private control. You cannot save a private script with the same category + name as an existing shared one.
SOQL Tab
The SOQL tab has two sub-tabs: ⚡ Query, a full-featured query editor (run with Run Query or Cmd/Ctrl+Enter), and 🔎 Record Detail, which shows one record in full and lets you edit it.
The editor supports:
Keyword highlighting — clauses, functions, string and number literals and comparison operators are colour-coded as you type, using your VS Code theme's colours.
Auto-capitalized keywords —
select,from,where,and,order by… are uppercased automatically the moment you finish typing them, matching the Salesforce documentation convention. Object and field names are left exactly as you typed them — including standard objects likeOrderorGroup, which are never mistaken for theORDER/GROUPclause keywords.Query tabs — keep several queries open at once. Use + to add a tab, ⧉ to clone the active one (query, name and Tooling toggle carried over), double-click a tab to rename it, drag a tab to reorder it, and × to close it. New tabs start pre-filled with
SELECT Id FROM(cursor ready for the object name). Tabs are named after the object in theFROMclause as you type —Order, and repeats getOrder (1),Order (2). A query opened from History ▾ → Saved takes that query's own saved name, and keeps it for as long as the query still targets the same object; change theFROMobject and the tab goes back to naming itself after it. Double-click to give a tab your own name instead — that one sticks for good; clear it to hand the tab back to automatic naming. Tab names and queries are saved per workspace and restored when you reopen the panel (results are not persisted).History — every query you run is recorded under History ▾ → Recent (newest first, deduped); a query set to run against the Tooling API is marked with a Tooling badge to the left of its text, matching the method badge on the REST tab's history. Click ★ Save to store the current query under a name (History ▾ → Saved). The name box comes pre-filled with the current tab's title, so a tab you already renamed (or opened from Saved) saves under that same name in one keystroke — and if you type a different name before confirming, the open tab is relabeled to match. Picking any entry opens it in a new tab, so it never overwrites the query you have open — unless the current tab is still empty, in which case it's reused rather than leaving a blank tab behind. Picking a Saved entry that's already open in one of your tabs switches to that tab instead of opening a duplicate — resetting it to the saved query right away if you haven't touched it, or asking first if it has changes you haven't saved.
SOQL autocomplete — as you type, suggestions appear for sObjects (after
FROM), fields and relationships (inSELECT/WHERE/ORDER BY/GROUP BY, including dotted traversal likeAccount.Owner.Name), and picklist values insideWHERE … = '…'. PressCtrl/Cmd+Spaceto force suggestions;↑/↓to move,Enter/Tabto insert,Escto dismiss.Browse fields (🗂 Fields) — for when you don't know what's on the object at all, not just what to type. Opens a full-width drawer below the editor listing every field on the object your query selects from: Type, whether it's Required/Custom/Unique/an External Id, whether it's usable in a
WHERE/ORDER BY/GROUP BYclause, and its Help Text (where the admin set one, shown last so a long one never pushes the other columns around) — updated automatically as you change theFROMclause or switch tabs. Drag its bottom edge to resize how much of it you see. Tick a field's checkbox to add it to theSELECTlist; untick to remove it — your query's own formatting and line breaks are left untouched. A lookup field (e.g.Owner) expands with › to show the fields reachable through it, so ticking one writes the full dotted path (Owner.Name). A picklist field expands to show its literal values — click one to insert it as a quotedWHEREliteral at your cursor. Use the object button at the top of the panel to browse a different object's schema without changing your query — while doing that, checkboxes are hidden (a field from another object can't be added to this query) and a banner offers a one-click way back.Tooling API — tick Tooling API to run the query against the Tooling API (e.g.
ApexClass,Flow).Write the query for me (✨ Ask AI) — describe the records you want in plain language, or ask about the query you already have open. See below.
Explained errors — when a query fails, the Salesforce error is shown exactly as returned, with an explanation added underneath.
This matters most for permissions. Salesforce rejects a field you're not allowed to see with the same
No such column 'X' on entity 'Y'message it uses for a genuine typo, so you end up hunting for a spelling mistake that isn't there. Force Cockpit checks the field against the org's full metadata and tells you which it is:🔒
AssetReferenceId__cexists but field-level security is hiding it — The field is defined on QuoteLineItem (Asset Reference Id, Text), but your user has no Read access to it. Ask an admin to assign you one of the permission sets below, or add Read access to this field on one you already have. Granted by:Sales_Ops_Extended (Permission Set)Field_Access_PSG (Permission Set Group)It also names which permission set or permission set group would actually fix it, so you're not just told "ask an admin" — you can ask for a specific one. If nothing currently grants it, it says so instead of guessing.
If the field really doesn't exist you get a Did you mean: list of the closest names instead. The same applies to mistyped relationships (
Accont.Name) and objects, including objects that exist but that your user cannot access.Reading the full field list, and looking up which permission set grants it, both need the View Setup and Configuration permission. Without it you still get suggestions — the message just says that a field hidden by field-level security couldn't be ruled out.
The results table supports:
- Large results — Salesforce returns a query in batches of up to 2,000 rows. When there is more, Force Cockpit keeps loading the rest in the background (the hint next to Run Query shows
Loading rows… 12,000 of 48,213) and shows the table once, complete. It never loads more than 50,000 rows: past that the meta line says so (Showing 50,000 of 120,000 records — stopped at the 50,000-row limit) and the answer is a narrower query. ✕ Cancel stops loading and shows the rows already loaded. Filter, sort, copy and export act on the loaded rows, so check the meta line before treating an export of a very large result as the whole thing.SELECT COUNT()is unaffected. - Filter — type in the filter box above the table to narrow rows by a case-insensitive match across all columns; a counter shows how many of the loaded rows match (it says
loadedwhen the result was cut short). - Sort — click any column header to sort; click again to reverse.
- Copy a column — click the ⧉ button on a column header to pick a copy format for that column's values: one per line (
a⏎b), comma-separated (a,b), quoted list ('a', 'b') or IN-clause (('a', 'b'), ready to paste after aWHERE … IN). Every format copies the current view — so it respects the filter and the sort — with duplicates and blanks dropped. - Copy a cell — hover any cell and a ⧉ button appears at its left edge; one click copies that cell's full value, including the part cut off by the column width.
- Open records — any Salesforce record Id in a cell renders as a link that opens the record in your browser. Hover the row and a 🔍 appears next to the Id: click it to open that record in the Record Detail instead.
- Export — Export CSV / Export JSON writes the current (filtered and sorted) view to a timestamped
query-result-…file in your workspace root and opens it in the editor. - Chart it (📊 Chart) — swaps the table for a chart of the same rows. Force Cockpit picks a sensible default: the first text column as labels (record Ids are skipped), the numeric columns as series, and a bar, pie or single-number Metric depending on the shape of the result. Change the type, the label column and which values to plot from the controls above the chart. The chart follows the table's filter and sort: type in the filter box and it narrows. Up to 100 points are drawn; past that a note says how many were left out. ▦ Table switches back.
- Pin to Monitoring (📌) — saves the chart as a Monitoring dashboard card. Give it a name (the tab's title by default), a category (
soqlby default), a refresh interval (Manual, 1, 5 or 15 minutes), and tick Private to keep it out of git. The card appears in the Monitoring tab at once (Open in Monitoring takes you there), and any edit form you had open there is left alone. The saved query is the one that produced the rows on screen, not whatever you have typed into the editor since. Pinning never overwrites an existing chart: a clashing name and category is refused with a message. Thresholds and notifications are set afterwards from the card's own Edit form. A query run against the Tooling API cannot be pinned, because Monitoring runs on the Standard API.
Generating a query with AI
Click ✨ Ask AI in the query toolbar and describe what you want — "opportunities for Acme that closed this year".
What's on your screen goes along with your question — both the query in the editor and the results of your last run. So you can ask about a query you're stuck on ("why doesn't this work?", "also filter by owner", "make this count by stage instead") and about the data that came back ("why is Amount empty on these?", "which of these have no owner?"). The assistant starts from your query and changes only what you asked for. If you're asking why it fails, it runs your query first so it's explaining the real error rather than guessing at one.
Only a small sample of rows is sent (the first few, trimmed to stay within the model's limits) along with the true row count — so the assistant knows how many records you really matched, and runs a query when it needs more than the sample can tell it.
Either way, it works against your connected org:
- Finds the real schema. It narrows to the objects your request most likely means and looks up their fields, so it uses your org's actual API names rather than guessing. If you didn't type an object name exactly, it searches for the closest match instead of giving up.
- Proves the query runs before showing it to you. Every candidate is executed against your org, capped at one row. If it fails, the assistant reads the same explained errors described above — including whether a field is genuinely missing or merely hidden from you by field-level security — and fixes the query itself, rather than handing you something broken.
- Checks it means what you asked. It compares the fields that actually came back against your request, and tells you how it read anything vague ("closed this year" →
IsClosed = true AND CloseDate = THIS_YEAR). If the query is valid but matched no records, it says so and names the filter it suspects. - Asks when it genuinely can't tell. If "Acme" could plausibly mean two different fields, you get a short question instead of a guess. Answer in the same box — it's a conversation, so you can also refine a query it already proposed ("only ones over £50k").
The proposed query appears with ▶ Run query, which drops it into the current tab and runs it exactly as if you had typed it — including ticking Tooling API for you when the query needs it. The conversation above shows each check as it happens, so you can see exactly what was run against your org and what came back.
Everything the assistant can do here is read-only: it looks up schema and runs SELECT queries, and can never modify data. Note that the sample rows described above are sent to the language model along with your question, the same way any data it queries itself is. Requires GitHub Copilot (see AI Scripts for model setup); New chat starts a fresh conversation, and switching orgs clears it automatically.
Record Detail
The 🔎 Record Detail sub-tab shows a record with every field your user can read, including the ones missing from its page layout, and lets you edit it without leaving VS Code. Each record you open gets its own tab, so related records can be open side by side.
Open a record — paste a record Id (15 or 18 characters) and press Show (or
Enter), or click the 🔍 next to any Id in the Query results or in a Monitoring table card. The object is worked out from the Id itself. A record one of your tabs already holds is brought to the front instead of opened twice. Pressing Show on the record you're already looking at re-fetches it.Read it — one row per field: label, API name, type, value, the same Required/Custom/Unique/External Id/Filter/Sort/Group flags as the field browser, and Help Text. A long value or help text is cut to one line — click it to expand the full text, click again to collapse. Type in the filter box to narrow the fields by label, API name, type or value. ↗ Open in Salesforce opens the record in your browser.
Tabs — a tab is named after its record (
Acme, or the object and the end of the Id for records with no name, likeCase Comment 001AAA). + opens a blank tab, ⧉ Clone duplicates the current one, × closes, double-click renames, drag to reorder. Each tab keeps its own record, edits and filter, and one can keep loading or saving while you work in another. Closing a tab with unsaved changes asks first.Follow lookups — a lookup's value (e.g.
OwnerId) is a link that opens the related record in a new tab, so the record you came from is still there — just click its tab.See what field-level security hides — a field you could edit if you had the permission is marked 🔒 No edit access, and fields you can't read at all are listed too, marked 🔒 No read access (name and type only — their value can't be fetched). Hover the badge to see which permission sets or permission set groups would give you that access, or that none currently does. The header counts both. Fields that are read-only for everyone — formulas, auto-numbers, audit fields like Created Date — aren't marked. Listing hidden fields and naming permission sets need the View Setup and Configuration permission; without it you still see the read-only markers.
Edit it — fields you're allowed to change become inputs: a dropdown for picklists, a checkbox for booleans, date and date-time pickers, and text boxes for everything else. Formula fields and fields you can't write stay read-only. Changed rows are highlighted.
Save safely — Review changes shows each field's value before and after; Confirm save then sends only the fields you changed. Clearing a field sets it to empty (
null) in Salesforce. On a production org or a protected sandbox you're asked to confirm first, and if Salesforce rejects the save (a validation rule, a required field) its own error message is shown and your edits are kept so you can fix them. Unsaved changes are never discarded silently: re-loading the same record, or closing its tab, asks first.Compare — ⇄ Compare (in the record header) lines up to four records of the same object in one table. The record you're on plus other loaded ones of that object are picked for you; tick or untick others from the chips above the table. Tabs restored from your last session that look like the same object are listed too and load when you tick them — if one can't be loaded (deleted, or no longer visible to you) its chip says why and it's left out of the table. Only differences is on by default and the first column is the baseline the others are highlighted against. Click a column header to jump to that record's tab, ↻ Reload to re-fetch them all. Compare is read-only.
Your tabs are remembered per org, so reopening the panel — or switching back from another org — brings back the records you had open. They come back empty and load as you click them, and nothing you'd edited is kept.
Related lists, creating and deleting records, bulk edits, and comparing records of different objects aren't supported.
Apex Tab
The ▶️ Apex tab runs anonymous Apex against the connected org — the Developer Console's Execute Anonymous, without leaving VS Code.
- Write and run — type Apex in the editor and press ▶ Execute (or
Cmd/Ctrl+Enter).Tabindents. It runs as you, in the connected org, and DML is committed: on a production org or a protected sandbox you're always asked to confirm first. - Tabs — keep several snippets open at once. A tab is named after the first meaningful name in its code (
UserInfo,Account,MyInvoiceService), orApexwhen there is none; double-click to rename, drag to reorder, ⧉ Clone to duplicate. A run keeps going in its own tab while you work in another, and its result lands in the tab that started it. - History — every snippet you run is recorded under History ▾ → Recent; ★ Save stores the current one under a name. Picking an entry opens it in its own tab.
- Log level — pick one of the Debug Logs presets (Balanced, USER_DEBUG only, SOQL deep dive, …). It applies to this run only and overrides any trace flag you have set.
- Results — a success, a compile error (with its line and column, and a Go to line button that selects it in the editor), or an uncaught exception with its stack trace. The debug log appears below with the same summary bars, detected issues, category chips, execution tree and query table as the Debug Logs tab. Use the category chips (Errors, USER_DEBUG, SOQL / SOSL, …) to narrow it to what you care about. Open raw in editor and Copy to clipboard take the raw log.
- Cancel — ✕ Cancel stops waiting, but Apex can't be stopped once it reaches Salesforce: the transaction may still complete on the server.
- Save as script — 📜 Save as script opens the Scripts tab's new-script form with your snippet already in it, ready to name and file.
Your tabs, history, saved snippets and log-level pick are saved per workspace; results are not.
Monitoring Tab

The Monitoring tab displays live charts built from SOQL queries. Each chart is defined by a YAML configuration file. Charts are rendered using Chart.js and can be refreshed manually or on a timer.
Where charts come from
Charts are loaded from two sources (merged at runtime, later wins):
| Source | Path | Purpose |
|---|---|---|
| User-defined | {workspace}/force-cockpit/monitoring/{category}/*.yaml |
Your own charts, committed to git |
| Private | {workspace}/force-cockpit/private/monitoring/{category}/*.yaml |
Personal charts, not committed to git |
The user-defined path can be customised via the VSCode setting forceCockpit.cockpitPath (see Configuration).
Private charts
Checking Private in the chart edit form saves the config to force-cockpit/private/monitoring/ instead of the shared folder. The extension automatically adds force-cockpit/private/ to .gitignore on startup so these files are never committed.
Private charts show a 🔒 badge on their card. Use the All / Shared / Private filter above the category pills to show only the configs you care about.
You cannot save a private chart with the same category + name as an existing shared one (and vice versa) — the extension will show an error.
Sub-categories
Monitoring configs support two levels of nesting: {category}/{sub-category}/*.yaml. Clicking a parent category pill reveals a second row of narrower sub-pills to drill down.
Adding a new monitoring chart
Pick or create a category folder under
force-cockpit/monitoring/in your workspace:{workspace}/ └── force-cockpit/ └── monitoring/ └── orders/ ← any name you like └── my-chart.yamlCreate the YAML file using the schema below.
Reload the Monitoring tab — your chart appears automatically. No rebuild or restart needed.
Deleting a chart
Click Edit on the card → click the red Delete button in the form → confirm in the modal. User and private charts are removed from disk. Built-in charts — the example dashboards under force-cockpit/monitoring/, which you get by cloning this repo rather than from the extension itself — cannot be deleted from disk, so they are hidden in your workspace instead. A "Restore hidden built-ins (N)" link appears in the top toolbar so you can bring them back.
YAML schema
name: Open Orders by Status # Display name shown on the card
description: Count of open orders grouped by status. # Subtitle shown on the card
soql: |
SELECT Status, COUNT(Id) RecordCount
FROM Order
WHERE Status != 'Cancelled'
GROUP BY Status
labelField: Status # API name of the field used as chart labels (X-axis or pie slices)
valueFields: # One or more datasets to plot
- field: RecordCount # API name of the numeric field
label: Orders # Legend label for this dataset
format: number # optional: number | currency | percent
chartType: bar # bar | line | pie | doughnut | metric | table
stacked: false # true = stacked bars/lines (bar and line only)
notifyOnIncrease: false # true = fire a notification when totalRows grows between two refreshes
refreshInterval: 0 # Auto-refresh in seconds. 0 = manual refresh only
Field reference
| Field | Required | Values | Description |
|---|---|---|---|
name |
Yes | string | Card title |
description |
No | string | Card subtitle |
soql |
Yes | SOQL string | Any valid SOQL query |
labelField |
Yes* | API name | Field whose values become chart labels or the first table column. *Not required for metric type. |
valueFields |
Yes | array | At least one { field, label } entry |
valueFields[].field |
Yes | API name | Field to plot or display |
valueFields[].label |
Yes | string | Dataset legend label or column header |
valueFields[].format |
No | currency | percent |
Number formatting on axes, tooltips, and table cells |
chartType |
No | bar | line | pie | doughnut | metric | table |
Default chart type (user can override for chart types) |
stacked |
No | true | false |
Stack bars or lines (bar and line only) |
notifyOnIncrease |
No | true | false |
Fire a VSCode warning whenever the row count grows between two auto-refreshes (e.g. new error records appearing). Snoozable for 1 hour or for the day. |
refreshInterval |
No | integer (seconds) | 0 disables auto-refresh |
Background notifications: Charts with thresholds or
notifyOnIncrease: truekeep auto-refreshing in the background even when the Force Cockpit panel is closed, so threshold breaches and row-count growth alerts still fire. Row-count growth also plays a short OS audio cue — best-effort, using your platform's own sound player (afplayon macOS, PowerShell'sSoundPlayeron Windows,paplay/pw-play/canberra-gtk-playon Linux). If none of them is available the notification still appears, just silently. Charts without these flags only refresh while the panel is open. Disconnect from the org and the background polling stops.
Multiple datasets (grouped charts)
You can plot multiple fields from the same query side by side:
name: Order Amounts by Status
soql: |
SELECT Status, SUM(TotalAmount) Total, COUNT(Id) Count
FROM Order
GROUP BY Status
labelField: Status
valueFields:
- field: Total
label: Total Amount (€)
format: currency
- field: Count
label: Number of Orders
chartType: bar
refreshInterval: 60
Stacked bars
Add stacked: true to a bar or line chart with multiple valueFields to render them as stacked segments:
name: Revenue by Category
soql: SELECT Name, Hardware__c, Software__c, Services__c FROM Account__c
labelField: Name
valueFields:
- field: Hardware__c
label: Hardware
format: currency
- field: Software__c
label: Software
format: currency
- field: Services__c
label: Services
format: currency
chartType: bar
stacked: true
Metric cards (KPI)
Use chartType: metric to display a single large number. labelField is not required. The first value of the first valueField is shown as the headline number:
name: Open Orders
description: Total orders waiting to be processed.
soql: SELECT COUNT(Id) Cnt FROM Order WHERE Status = 'Open'
valueFields:
- field: Cnt
label: Open Orders
chartType: metric
refreshInterval: 30
Table view
Use chartType: table to render a scrollable, sortable table. Works with any SOQL — aggregate or not. Click any column header to sort. Use format: currency or format: percent on valueFields to format numeric columns.
Each table card has a search box above the table that filters its rows in real time by any field (case-insensitive substring match across every column). A small counter next to the input shows X of Y so you can see how aggressive your filter is. The filter text persists across auto-refresh of the same card.
Any cell whose value is an 18-character Salesforce record Id (validated via the standard Salesforce case-safe checksum) is rendered as a clickable link that opens the record in your browser — no extra configuration needed. Hover the row and a 🔍 appears next to the Id: click it to open that record in the SOQL tab's Record Detail instead, with every field readable and editable. This works for Id, OwnerId, AccountId, and any other lookup or aliased Id column. 15-character Ids pasted into custom text fields are not auto-linked, since they have no checksum to verify and would risk false positives on plain text values.
name: Recent Orders
description: Last 20 orders by creation date.
soql: |-
SELECT OrderNumber, Status, TotalAmount
FROM Order
ORDER BY CreatedDate DESC
LIMIT 20
labelField: OrderNumber
valueFields:
- field: Status
label: Status
- field: TotalAmount
label: Amount (€)
format: currency
chartType: table
Examples
[!TIP] Repository examples: There are example charts in this repository under
force-cockpit/monitoring/examples/.
Editing and saving charts in the UI
Each card has an Edit button that opens an inline form. Changes to the SOQL field trigger an auto-preview after 800 ms. Check Private to save to the private folder; leave unchecked to save to the shared workspace path. Clicking Save writes the YAML — it never overwrites a built-in chart's own file.
REST Tab
The REST tab lets you call any REST API or Apex REST endpoint on the connected org, reusing the extension's authenticated session — no need to copy access tokens or set up a separate HTTP client.
- Pick an HTTP method —
GET,POST,PUT,PATCH, orDELETE. - Enter the endpoint path — a relative path is prefixed with the org's instance URL automatically, e.g.
/services/data/v65.0/limitsor/services/apexrest/api/orderUpdate. A fullhttps://…URL is used as-is. Use the 📋 button to paste from the clipboard. - Add custom headers (optional) — click + Add header for any extra headers your endpoint needs (e.g.
Sforce-Auto-Assign). A defaultContent-Type: application/jsonheader is sent; a custom header with the same name overrides it. - Add a JSON body (optional) — used for
POST/PUT/PATCH; ignored forGET/DELETE. - Send the request with the Send button or
Cmd/Ctrl+Enter. Use ✕ Cancel to abort one that is taking too long — the request is genuinely cancelled, not just ignored. The response shows a color-coded status code, a collapsible list of response headers, and the pretty-printed JSON body — any Salesforce record Id in the response is a clickable link that opens the record in your browser. The response box border is colored to match (green/amber/red). A non-2xx response (e.g.404,400) is shown as a normal response, not an error — only network-level failures (e.g. no connectivity) show as an error. Below the response body, use Open in editor to view the full body in a native VSCode editor tab, or Copy to clipboard to copy it directly.
Request tabs — keep several requests open at once. Use + to add a tab, ⧉ Clone to copy the active one (method, endpoint, body and headers carried over), double-click a tab to rename it, drag a tab to reorder it, and × to close it. Tabs are named after the last part of the endpoint as you type — /services/data/v65.0/sobjects/Account becomes Account, and repeats get Account (1), Account (2); a tab with no endpoint yet is named after its method (POST Request). Double-click to give a tab your own name instead — that one sticks for good; clear it to hand the tab back to automatic naming.
Each tab sends independently, so you can fire off a slow request in one tab and carry on working in another — a tab with a request in flight shows a ⋯ next to its name, and its response lands in that tab whichever one you are looking at when it arrives.
History & saved requests — every successful send (including non-2xx responses) is recorded in the History ▾ dropdown, with the method badge first and the endpoint next, so you can quickly re-run or tweak a past request. Click ★ Save to name and keep a request permanently under "Saved" — useful for endpoints you call often. The name box comes pre-filled with the current tab's title, so a tab you already renamed (or opened from Saved) saves under that same name in one keystroke — and if you type a different name before confirming, the open tab is relabeled to match. Picking anything from History ▾ opens it in its own tab, so it never overwrites the request you have open (an empty tab is reused rather than leaving a blank behind) — unless the entry is already open in one of your tabs, in which case that tab is reused instead of duplicated: reset to the saved request right away if you haven't touched it, or after confirming if it has changes you haven't saved. A request opened from Saved takes that request's own saved name and keeps it for as long as the endpoint still points at the same resource; change the endpoint and the tab goes back to naming itself after it.
Your tabs (method, endpoint, body, headers and names) are saved per workspace and restored when you reopen the panel — responses are not persisted. If you used the REST tab before it had tabs, the request you had open becomes your first tab.
[!WARNING] When you are connected to a production org or a protected sandbox, sending a
POST/PUT/PATCH/DELETErequest prompts for confirmation first, since these verbs can modify live data.GETrequests are sent without a prompt.
Debug Logs Tab
The Debug Logs tab covers the whole debugging loop without leaving VSCode: turn logging on, watch the logs arrive, make sense of them, and get an explanation.
1. Set a trace flag
Choose what to trace:
- Me — the user you are connected as.
- Automated Process — async work (platform-event triggers, resumed flows, batch retries) logs under the Automated Process user, separately from whoever triggered it. Setup cannot create a trace flag for these system users at all; Force Cockpit does it through the Tooling API. Other platform/integration users are listed here too.
- Search user… — any user in the org, by name or username.
- Apex class / trigger… — creates a
CLASS_TRACINGflag that raises the log levels inside one class or trigger without generating its own log. Combined with a quiet user-level flag, this is how you getFINESTdetail on the suspect code without truncating the log.
Then choose a debug level. Each preset explains itself in the dropdown and in the hint underneath — what it is for, what it captures, and the exact category levels it applies:
| Preset | When to use it |
|---|---|
| Balanced ⭐ Recommended | The default, and the right answer when you don't know which to pick. Debug output, which classes ran, every SOQL/DML statement and the limit summary — without truncating. |
| USER_DEBUG only (quiet) | You only care about your own System.debug() lines and want the smallest possible log. |
| SOQL / database deep dive | A query is slow, non-selective or returns the wrong rows. Full query text, row counts and timings. |
| Flow & Process Builder | A Flow or record-triggered automation misbehaves — shows flow elements and their variable values. |
| Integration / callouts | An outbound callout fails — logs the full request and response bodies. |
| Governor limits / performance | The transaction hits (or nearly hits) a limit — full cumulative-usage breakdown. |
| Deep trace (FINEST) | Last resort. Every statement and variable assignment; fills the 20 MB budget fast, so pair it with a class trace. |
| Production-safe (errors) | Tracing on production or a busy integration user. Preselected automatically on sensitive orgs. |
Or open Custom… to set all eight categories by hand. Pick a duration (15 minutes to the 24-hour platform maximum) and press Start tracing. Salesforce allows only one active trace flag per entity, so starting a trace on something already traced updates the existing flag instead of failing. Active flags are listed with a live countdown and Extend / Stop buttons.
2. Read the log list
Logs are listed newest first, sortable, with a text filter and:
- Errors only — only transactions that ended with an exception.
- Hide empty logs — a real org fills this list with Lightning/Aura round-trips and no-op triggers. "Empty" means the transaction did nothing observable: no debug output, no error, no SOQL and no DML. Force Cockpit checks the log bodies to decide that (fetched in small batches and cached), plus a free pre-filter on the operation name for recognisable UI chatter. Size and duration are deliberately not used — a useful anonymous-Apex log is only ~1.5 KB and runs in a few milliseconds, so "small" would hide exactly what you came for. A
N hidden as emptychip with a Show button means nothing ever disappears silently, and a failed transaction is never hidden however small it is. - Search inside the logs — typing in the filter box filters the list's own columns (user, operation, status…) instantly. Press Enter to also search the content of every listed log — an error code such as
REQUIRED_FIELD_MISSING, your ownSystem.debugmarker, a record Id. The logs are fetched in small batches, and a row then shows if its columns or its content contain the text (case-insensitive); a content hit shows how many lines matched and the first matching line. Results appear as they are found and ✕ Cancel stops early, keeping what was found. Editing the text drops the content results until you press Enter again. Opening a matching log fills the viewer's search box and jumps straight to the first hit. Logs that arrive through Live tail after a search are not scanned — the status line says so; press Enter again to include them. - Live tail — polls the org while the tab is open; a new failed transaction raises a notification. On by default.
3. Make sense of one log
Click a log to open it:
- Summary — statement counts, query rows, and a bar per governor limit, coloured as it approaches the ceiling. A
⚠ truncatedchip appears when Salesforce cut the log short. - Issues — built-in rules flag SOQL or DML in a loop, N+1 query patterns, governor-limit pressure, unhandled exceptions with their stack frames, recursive triggers, very wide queries, and truncation. Click one to jump to the line.
- Log / Tree / Queries — Log colour-codes lines by category with multi-select chips (Errors, USER_DEBUG, SOQL, DML, Callouts, Limits, Code units, Flow, Validation) and a default-on Hide noise toggle; the None chip clears every filter, including Hide noise, to show the whole untouched log; Tree is the call tree with total/self milliseconds and a timeline bar; Queries is a sortable table of every SOQL statement in the transaction, each rated Full scan / Not selective / Selective / Unknown from its query plan (leading operation, indexed field, cardinality vs. object size, Salesforce's own relative-cost estimate) so a poorly-performing query stands out without reading raw lines — click a row to jump to it in the Log. Search jumps between matches in any mode.
4. Ask AI what happened
✨ Analyze with AI opens the analysis panel — model picker, "Read workspace files" / "Query the org" toggles, and an optional "what should the analysis focus on?" field — without sending anything yet, so you can set it up first. The panel scrolls into view and flashes briefly since it sits below the summary/issues/log output and is easy to miss otherwise. Click Run analysis to actually send the log through the VS Code Language Model API (GitHub Copilot), the same mechanism as AI scripts.
Debug logs are far too large for any context window, so the model receives a briefing — metadata, the captured log levels, limit usage, detected issues, every error with its surrounding lines, and the debug output — and pulls anything else it needs on demand: it can search the full log, read any range of lines, and inspect the call tree. It can also read your workspace files (to open the Apex class named in a stack frame) and, if you tick Query the org, run read-only SOQL for extra context — including against the Tooling API for metadata objects, when it decides it needs to — and it knows which user is connected, so a question about "my" access has an answer.
The analysis always covers: what happened, the root cause with line references, governor-limit pressure, ranked concrete fixes, which log levels to use next time, and what it is unsure about. That last recommendation comes back as a one-click Apply these levels button that pre-fills the trace-flag form above, so the next repro captures exactly what was missing.
Use Open as markdown for a rendered view, Save analysis to write it into force-cockpit/logs/ (where it shows up under Scripts → Logs), or Copy.
[!NOTE] Trace flags and debug logs consume org resources, and logs are retained for 24 hours (or 7 days for logs collected via a trace flag on another user). Delete logs you no longer need with the Delete / Delete all buttons.
Plugins Tab
The other tabs cover what most people need most of the time. A plugin covers the rest: a screen of your own, doing the thing your team does over and over.
A plugin is a folder. Drop it into force-cockpit/plugins/, and it shows up as
a sub-tab with your markup, your styles, your JavaScript — and the org
connection already wired up. There is no build step, no npm install, no
packaging, and nothing to publish.
force-cockpit/plugins/apex-jobs/
plugin.yaml # required — name, description, icon
view.html # required — your markup
view.js # your webview code
view.css # your styles (optional)
handlers.js # your Salesforce logic, running in the extension
The folder name is the plugin id. Put a plugin in
force-cockpit/private/plugins/ instead to keep it out of git (see
Private scripts — the same .gitignore covers it).
plugin.yaml
name: Apex Jobs # required — the sub-tab label
description: Watch batch Apex, and stop one that has gone wrong. # sub-tab tooltip
icon: '⚙️' # optional emoji, prefixed to the label
handlers.js — your Salesforce logic
Runs in the extension host, not the browser, so it has the org connection. Each
handler is a function assigned onto exports:
exports.list = async ({ filter }) => {
const result = await query(
`SELECT Id, Status, ApexClass.Name FROM AsyncApexJob ${whereFor(filter)} LIMIT 50`,
);
log(`${result.records.length} job(s).`); // streams to your panel live
return result.records; // whatever you return lands in view.js
};
Available as globals — no imports:
| Global | What it does |
|---|---|
query(soql) |
Run SOQL |
executeApex(apex) |
Run anonymous Apex, with the debug log |
restCall(method, endpoint, body?, headers?) |
Any REST/Apex REST endpoint, with status and headers |
connection, org |
The raw jsforce connection and the current org |
run(cmd) |
A shell command in the workspace root |
log(...), error(...), console |
Stream output to your panel |
fs, os, path, yaml |
Node modules |
require(path) |
Your own helper files (see below) |
pluginDir, pluginId, workspaceRoot |
Where you are |
Anything you throw reaches view.js as a rejected promise with your message.
handlers.js is re-read every time a handler runs, so editing it takes
effect on your next click — no reload. Changes to view.html, view.js,
view.css or plugin.yaml need Force Cockpit: Reload Plugins from the
command palette.
Splitting handlers.js up
One file is right for a small plugin. But handlers are cheap to add and each one drags in guards and formatting, so a plugin that started at 30 lines can reach 400 — with the CSV escaping sitting next to the Id validator next to the SOQL field list, all in one scope, none of it testable on its own.
So require works, for your own files:
force-cockpit/plugins/apex-jobs/
handlers.js ← the handlers, and little else
lib/jobs.js ← the query and row projection
lib/validate.js ← the guards
// handlers.js
const { listJobs } = require('./lib/jobs.js');
const { assertJobId } = require('./lib/validate.js');
exports.list = async ({ filter }) => listJobs(filter);
exports.abort = async ({ jobId }) => {
assertJobId(jobId);
await executeApex(`System.abortJob('${jobId}');`);
return { aborted: jobId };
};
// lib/jobs.js — the sandbox globals are here too, nothing to pass down
exports.listJobs = async (filter) => {
const r = await query(`SELECT Id, Status FROM AsyncApexJob ${whereFor(filter)}`);
log(`${r.records.length} job(s).`);
return r.records;
};
It is CommonJS require, with the differences you would expect from a plugin:
- Your own files only. Relative paths, inside your plugin folder —
../out of it is refused.require('fs'),require('os'),require('path')andrequire('js-yaml')also work, returning the same objects as the globals. npm packages are not available, andrequire('lodash')says so. ./lib/jobs,./lib/jobs.jsand./lib/(anindex.js) all resolve, as in Node.- Cached per click, not forever. Two files sharing a helper load it once; an edit to any of them is still live on your next click.
- Circular requires behave as Node's do rather than hanging.
view.js — your panel
Loaded as an ES module, so you can split it with import the same way. Talk to
your handlers through window.__fcPlugin:
const fc = window.__fcPlugin('apex-jobs');
refreshBtn.addEventListener('click', async () => {
// Passing the button gives you a spinner and a ✕ Cancel for free.
// Leave it out — as a background poll should — and nothing is disabled.
const jobs = await fc.invoke('list', { filter: 'active' }, { button: refreshBtn });
render(jobs);
});
fc gives you invoke(handler, args, { button, onChunk }), connected, org,
onOrg({ onConnected, onDisconnected }), openRecord(id, { app }),
confirm(prompt), escapeHtml(s) and setTooltip(el, text).
openRecord(id) opens the bare Id and lets Salesforce pick the Lightning app,
which is what you want unless you already know the org's apps; the optional
{ app: 'Sales' } pins the record to one you name.
view.html is a fragment — no <html>, no <head>, and no inline <script>
(the panel's security policy blocks those; put your code in view.js). Ids are
shared with the whole panel, so prefix them with your plugin id. All the
extension's own CSS classes (card, btn, text-input, error-box) and
VS Code theme variables are available.
Production safety
You do not have to write a confirmation prompt. Against a production org — or a
sandbox listed in protectedSandboxes — Force Cockpit raises a native "are you
sure" modal by itself the moment a handler reaches executeApex, a
POST/PUT/PATCH/DELETE restCall, or run. Reads (query, a GET)
never prompt. Confirming once covers the rest of that handler run, so a loop of
a hundred updates asks once.
In the bundled example this is the whole story of its abort handler: it stops
a running batch job with one line of anonymous Apex and contains no
confirmation code at all, yet you still get a modal before anything is killed on
production.
If the user declines, your handler stops with Operation cancelled — the same
thing you get from ✕ Cancel, so you can treat them alike.
[!IMPORTANT] Plugins run with the same trust as your YAML scripts: a plugin can read and write files, run shell commands, and change data in your org. Only install plugins you have read or trust, exactly as you would a script.
A worked example
This repo ships Apex Jobs at
force-cockpit/plugins/apex-jobs/ — a real
tool, not a toy. It lists batch, queueable, future and scheduled Apex on the
connected org, filters by state, colour-codes the status, shows progress and
error counts, opens any job in Salesforce, optionally polls every 10 seconds,
and lets you stop one that has hung.
It is about 200 lines across the three files, and it demonstrates each thing you are likely to want:
| In the example | What to copy it for |
|---|---|
list handler |
Querying the org and reshaping rows for the UI |
abort handler |
A mutating action, gated automatically |
| Filter dropdown → a key, not SOQL | Keeping user input out of your query |
| The record-Id regex | Keeping user input out of an Apex literal |
{ button: refreshBtn } |
Spinner + ✕ Cancel on an action the user asked for |
Auto-refresh with no button |
A background poll that stays out of the way |
fc.openRecord(job.id) |
Opening a record in Salesforce |
fc.onOrg({ ... }) |
Reacting to org connect / disconnect |
Copy the folder into your own force-cockpit/plugins/, rename it, and start
editing.
Writing a plugin with an AI assistant? This repo ships an Agent Skill documenting the whole surface — the two realms and what may not cross between them, the
handlers.jsglobals andrequire, thewindow.__fcPluginAPI, the CSP rulesview.htmlhas to live inside, and the automatic production gate. Copy theforce-cockpit-plugins/folder into your workspace's.claude/skills/(or.github/skills/) and your assistant will write a valid plugin first time instead of guessing at the boundary.
Known limits
- Images must be files in your plugin folder; inline
data:URIs are blocked. - Neither
view.jsnorhandlers.jscan use npm packages or import from the extension's own source — only your own files. - A plugin with a broken
plugin.yaml, or without aview.html, still gets a sub-tab — showing the error, so you can see what to fix.
Configuration
Most extension settings are managed via a config.yaml file — making them easy to share across a team by committing the file to git.
The extension loads configuration in this order (later layers override earlier ones):
- Hardcoded defaults — built into the extension
- Bundled
config.yaml— shipped with the extension at its root - User
config.yaml— atforce-cockpit/config.yamlin your workspace (or the customcockpitPath)
Only keys present in a layer override the previous layer — omitted keys keep their default values.
Available settings
| Key | Type | Default | Description |
|---|---|---|---|
apiVersion |
string | "66.0" |
Salesforce API version for all API calls |
protectedSandboxes |
string[] | [] |
Sandbox org names that require confirmation before destructive actions |
skillsPaths |
string[] | [".claude/skills", ".github/skills"] |
Workspace-relative folders scanned for Agent Skills attachable to AI scripts |
debugLogs.noise |
object | see below | Thresholds for the Debug Logs tab's "Hide empty logs" filter |
debugLogs.noise keys — all optional:
| Key | Type | Default | Description |
|---|---|---|---|
maxEmptyBytes |
number | 0 (off) |
Opt-in: successful logs at or below this size count as empty without reading them |
maxEmptyDurationMs |
number | 0 (off) |
Opt-in: successful logs at or below this duration count as empty without reading them |
operationPatterns |
string[] | ["/aura", "aura.", "VFRemoting", "Lightning", "PushTopic", "ApexRestApi"] |
Case-insensitive substrings matched against the log's operation |
Example force-cockpit/config.yaml
apiVersion: '66.0'
protectedSandboxes:
- staging
- uat
skillsPaths:
- .claude/skills
- .github/skills
debugLogs:
noise:
# Optional shortcut: skip reading the body of very small/fast logs.
maxEmptyBytes: 4096
maxEmptyDurationMs: 100
operationPatterns:
- /aura
- VFRemoting
VSCode setting
One setting remains in VSCode's settings.json because it determines where the config file lives:
| Setting | Default | Description |
|---|---|---|
forceCockpit.cockpitPath |
"" |
Absolute path to the force-cockpit folder. Defaults to {workspace root}/force-cockpit if empty. |
Note: Changes to
config.yamlare picked up automatically — no window reload needed.
Releases
New versions are published automatically via GitHub Actions.
To create a release:
- Go to the Actions tab in the GitHub repository.
- Select Release → Run workflow.
- Choose the version bump type (
patch,minor, ormajor) or enter an explicit version string. - Click Run workflow.
The workflow will:
- Bump the version in
package.json - Update
CHANGELOG.mdwith the version and date - Push a version commit and git tag to
main - Build and package the
.vsix - Create a GitHub Release with the
.vsixattached - Publish the extension to the VS Code Marketplace
The .vsix for every release is available on the GitHub Releases page.
Development
Requires Node 24 (what CI uses). .npmrc sets engine-strict=true, so a
mismatched Node makes npm ci fail outright rather than warn.
npm install
npm run build # Build extension (copy assets + esbuild bundles)
npm run watch # Build in watch mode
npm run compile # TypeScript type-check only (both tsconfigs)
npm run lint # ESLint over src/ and scripts/, including the complexity gates
npm test # Vitest
npm run package # Build + create .vsix
npm run audit:prod # Check production dependencies for known vulnerabilities
npm install-scripts ls # List dependency install scripts not yet in the
# `allowScripts` allowlist in package.json (read-only)
Dependency install scripts run arbitrary code at npm ci time, so they are
allowlisted per-package in package.json's allowScripts. Approvals are pinned
to a version — if a bump of esbuild or @vscode/vsce-sign lands, re-run
npm install-scripts approve <pkg> in the same PR.
Code layout conventions
The extension has two compilation targets and the file extension tells you which one a file belongs to:
| Extension | Runs in | Checked by npm run compile |
|---|---|---|
.ts |
The extension host (Node) | tsconfig.host.json — no DOM |
.ts under a view/ dir |
The webview | tsconfig.host.json — no DOM, which is why it must stay DOM-free |
.ts under src/shared/ |
Both | Both configs — so it may import neither DOM nor Node |
.tsx |
The webview (browser sandbox) | tsconfig.webview.json — DOM + Preact JSX |
.js |
The webview | tsconfig.webview.json, only with a // @ts-check pragma |
There are three tsconfigs and only two of them are gates. tsconfig.host.json
and tsconfig.webview.json are the build, run by npm run compile, and they are
what enforce the separation above. tsconfig.json is for your editor only:
VS Code resolves a file to the nearest tsconfig.json and to nothing else, so
without it every .tsx file showed Cannot use JSX / react/jsx-runtime
squiggles even though the build was green. It is the union of the other two, so
it is deliberately weaker — it will not flag a host module reaching for
document. npm run compile still will.
The two middle rows are the confusable ones. A pure helper like
select-clause.ts or table-sort.ts runs in the browser but is compiled by
the host config, which has no DOM lib — that is not an oversight, it is the
mechanism that keeps those files importable from both sides. Reach for window
in one and the build fails; that is the rule working.
A few consequences worth knowing before you edit:
.tsxmeans "browser", not "has JSX". A DOM-coupled module carries that extension even with no markup in it, because it is the only thing keeping the host type-check from trying to compilewindow.- Filenames follow the layer, not the file type. Everything in the webview
is kebab-case (
card-builder.js,select-clause.ts,edit-form.tsx) — including Preact components, which are not PascalCase here. Host modules arePascalCasewhen they export a class andcamelCasewhen they export pure functions. Component symbols stay PascalCase either way. - Webview UI is Preact + signals for new code. Existing hand-written DOM modules are migrated as they are touched, not in bulk. Signals must not be shared across feature bundles — each bundle embeds its own Preact instance.
- Every host ↔ webview message name lives in
src/shared/protocol/. Both compilation targets import it, so a typo is a build error instead of a message that silently goes nowhere. Add the name there before you use it. - ESLint enforces complexity ceilings (
max-lines,max-lines-per-function,complexity). They are set just above the current worst offender and are tightened over time, so they should never fail on existing code — only on new code that is worse than anything already there.
Security
- Dependency auditing: Every PR runs
npm auditagainst production dependencies in CI. Dependabot opens weekly PRs when vulnerable packages have updates available. .npmrchardening:audit-level=high,engine-strict=true, andsave-exact=trueensure safe and reproducible installs.


