Skip to content
| Marketplace
Sign in
Visual Studio Code>Other>Force CockpitNew to Visual Studio Code? Get it now.
Force Cockpit

Force Cockpit

Noriabits

noriabits.com
|
45 installs
| (2) | Free
Salesforce utilities cockpit for VSCode
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Force Cockpit

Version Installs Rating Release License

A 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


Installation

From the VS Code Marketplace

  1. Open the Extensions panel (Ctrl+Shift+X / Cmd+Shift+X).
  2. Search for Force Cockpit.
  3. Click Install.

From a .vsix file

  1. Download the latest .vsix file from the releases.
  2. In VSCode, open the Extensions panel.
  3. Click the ... menu → Install from VSIX... and select the file.

Alternatively, install from the terminal:

code --install-extension force-cockpit-<version>.vsix

Prerequisites

  • Salesforce CLI (sf) must be installed and on your PATH.
  • You must be authenticated to at least one Salesforce org (sf org login web).

Getting Started

  1. Open a workspace that contains an SFDX project (or any folder).
  2. Use the Salesforce extension to set your default org (SFDX: Set a Default Org command, or click the org name in the status bar).
  3. Force Cockpit auto-connects to the target-org set in .sf/config.json at startup — and reconnects automatically whenever you switch orgs via the Salesforce extension.
  4. Open the cockpit panel via the Command Palette: Force Cockpit: Open Cockpit.

If you switch orgs while an operation is in progress, a confirmation dialog appears. Confirming cancels any running operations and connects to the new org.

If the panel doesn't pick up an org change automatically (e.g. the file watcher missed an event, or the SF CLI hasn't finished writing the new credentials), click the 🔄 button next to the connection status in the panel header to force a fresh re-read of .sf/config.json and a reconnect. The same 🔄 Refresh action also appears inside the "No org connected" card.


Tabs

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 Tab

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

Script Editor

[!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 _copy is 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 .yaml file 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 own force-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. Use setOutput(). 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 gather SOQL/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 (SELECT only) 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 like Selector matches OrderSelector, 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.md under .claude/skills or .github/skills in 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 with skillsPaths in force-cockpit/config.yaml.
  • Schema is cached locally. Before querying, the model checks object fields via a describe_object tool. Results are cached per workspace under force-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 and errorCode are 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 a Bearer header 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 call restCall(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 jsforce connection.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 like Order or Group, which are never mistaken for the ORDER/GROUP clause 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 the FROM clause as you type — Order, and repeats get Order (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 the FROM object 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 (in SELECT / WHERE / ORDER BY / GROUP BY, including dotted traversal like Account.Owner.Name), and picklist values inside WHERE … = '…'. Press Ctrl/Cmd+Space to force suggestions; ↑/↓ to move, Enter/Tab to insert, Esc to 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 BY clause, 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 the FROM clause or switch tabs. Drag its bottom edge to resize how much of it you see. Tick a field's checkbox to add it to the SELECT list; 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 quoted WHERE literal 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__c exists 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 loaded when 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 a WHERE … 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 (soql by 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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, like Case 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). Tab indents. 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), or Apex when 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

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

  1. Pick or create a category folder under force-cockpit/monitoring/ in your workspace:

    {workspace}/
    └── force-cockpit/
        └── monitoring/
            └── orders/          ← any name you like
                └── my-chart.yaml
    
  2. Create the YAML file using the schema below.

  3. 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: true keep 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 (afplay on macOS, PowerShell's SoundPlayer on Windows, paplay/pw-play/canberra-gtk-play on 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.

  1. Pick an HTTP method — GET, POST, PUT, PATCH, or DELETE.
  2. Enter the endpoint path — a relative path is prefixed with the org's instance URL automatically, e.g. /services/data/v65.0/limits or /services/apexrest/api/orderUpdate. A full https://… URL is used as-is. Use the 📋 button to paste from the clipboard.
  3. Add custom headers (optional) — click + Add header for any extra headers your endpoint needs (e.g. Sforce-Auto-Assign). A default Content-Type: application/json header is sent; a custom header with the same name overrides it.
  4. Add a JSON body (optional) — used for POST / PUT / PATCH; ignored for GET / DELETE.
  5. 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 / DELETE request prompts for confirmation first, since these verbs can modify live data. GET requests 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_TRACING flag 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 get FINEST detail 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 empty chip 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 own System.debug marker, 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 ⚠ truncated chip 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') and require('js-yaml') also work, returning the same objects as the globals. npm packages are not available, and require('lodash') says so.
  • ./lib/jobs, ./lib/jobs.js and ./lib/ (an index.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.js globals and require, the window.__fcPlugin API, the CSP rules view.html has to live inside, and the automatic production gate. Copy the force-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.js nor handlers.js can use npm packages or import from the extension's own source — only your own files.
  • A plugin with a broken plugin.yaml, or without a view.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):

  1. Hardcoded defaults — built into the extension
  2. Bundled config.yaml — shipped with the extension at its root
  3. User config.yaml — at force-cockpit/config.yaml in your workspace (or the custom cockpitPath)

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.yaml are picked up automatically — no window reload needed.


Releases

New versions are published automatically via GitHub Actions.

To create a release:

  1. Go to the Actions tab in the GitHub repository.
  2. Select Release → Run workflow.
  3. Choose the version bump type (patch, minor, or major) or enter an explicit version string.
  4. Click Run workflow.

The workflow will:

  • Bump the version in package.json
  • Update CHANGELOG.md with the version and date
  • Push a version commit and git tag to main
  • Build and package the .vsix
  • Create a GitHub Release with the .vsix attached
  • 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:

  • .tsx means "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 compile window.
  • 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 are PascalCase when they export a class and camelCase when 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 audit against production dependencies in CI. Dependabot opens weekly PRs when vulnerable packages have updates available.
  • .npmrc hardening: audit-level=high, engine-strict=true, and save-exact=true ensure safe and reproducible installs.
  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft