Driving the live JBrowse session
Desktop's run_javascript MCP tool runs an async JavaScript function body
inside the app's renderer and serializes what you return. A browser agent on
JBrowse Web runs the same code in the page, where the value is the last
expression. In scope either way:
session, the live MST session model: views, tracks, assemblies, dialogsrootModel, its parent: the jbrowse config, menus,sessionitselfpluginManager, the plugin registry: track, view and adapter types, extension pointsjb, the helper library below;jb.helprestates it in one stringsignal(Desktop only), anAbortSignalthat fires whentimeoutMsexpires; check it in long loopswindow, the real DOM. In Desktop that is an Electron renderer with nodeIntegration, so Node is onewindow.requireaway
The helper library
A helper exists where the raw model answers wrong without an error — an unrenamed refName, a display that replaced its subtree without a toast, a name several views could answer — and turns that into a throw or a report. Everything else is done with the model.
Orientation and building:
jb.sessionSummary()orients: views, tracks (display type, render phase), assemblies, visible regions,draweranddialog.jb.inspect(node?, maxBytes?): a live node's value, getters, actions andmodelType;Object.keyslists no MST action. Bare, it is the session.jb.listTracks(search?, limit?): the catalog,{ total, tracks }of{ trackId, name, type, assemblyNames }, 100 by default.jb.loadSessionSpec(spec, settleMs?)builds views from a spec (docs topic:"session-spec"), settles (default 30000) and answers the settle plus the summary. It replaces the session: yoursessionargument is dead afterwards, andjb.sessionis the live one.jb.addView(spec, settleMs?)opens one entry of a spec'sviewsbeside what is open; an unknown key throws first. Answers{ viewId }plus the settle.jb.setSession(document, settleMs?)rewrites the session as a document,jb.mst.getSnapshot(jb.session)edited: an entry keeping itsidis patched, one dropped is closed, one without is new, and a top-level key left out keeps its value.jb.fitToWindow(settleMs?)shrinks what is open until it fits the window.jb.addTrack({ location, index?, assembly?, name?, show?, viewId?, settleMs? })adds an absolute local path or a URL (relative refused), infers the format, shows it and settles; an unreadable file reports innotReady.jb.addTrack({ trackId, settings?, viewId?, settleMs? })shows a catalog track; the file keys are refused.jb.view(viewId?)is the open view. With several open and noviewIdit throws naming each, as dojb.trackModel,jb.visibleRegions,jb.addTrackandjb.getFeatures;viewIdcomes fromjb.sessionSummary().jb.trackModel(trackId, viewId?)is the shown track's live model; it throws when no view shows it.track.applyDisplaySettings(settings)stylesactiveDisplayin place and returns{ applied, unapplied, failed }; read the report, since an unapplied key is not an error.jb.describeSlots(confNode)lists every slot the node's schema defines, with type, description and default:jb.describeSlots(jb.trackModel('x').activeDisplay.configuration).
Reading:
jb.getFeatures({ trackId, loc?, assembly?, viewId?, regions?, byteLimit? }), orjb.getFeatures(trackId, loc?, { assembly?, viewId?, byteLimit? }), is the track's data as live Feature objects, over the visible region by default. It runsrenameRegionsIfNeeded, so a file spelling "ctgA" "contigA" answers. See Reading data directly.await jb.visibleRegions(viewId?)is the visible region as numbers ({ assemblyName, refName, start, end }).jb.waitReady(timeoutMs?)resolves when views and tracks finish loading and drawing (default 30000), answeringnotifications(error toasts),notReady(views failed orinitializing, tracks settled without drawing (phase) or drawn around config (notices)),offscreen(views taller than the window),draweranddialog. None raises a toast, so check this report.
The foundations:
jb.require(name)is the module registry plugins link against, by the same names ('@jbrowse/core/util','react'); a browser needsawait jb.ensureRequire()once first.jb.mstandjb.mobxare the whole mobx-state-tree and mobx APIs.jb.readConfObject(conf, 'slot')andjb.getConf(model, 'slot')read config slots, which are not plain properties.jb.rootModelis the root model.
What changed since v4
- One
LinearAlignmentsDisplaydraws pileup, coverage, read arcs and read cloud as its slots (readConnections: 'arc');LinearPileupDisplay,LinearSNPCoverageDisplay,LinearReadArcsDisplayandLinearReadCloudDisplayare aliases of it, not types toreplaceDisplayto. - A view's launch keys go directly on the view object in a spec or a snapshot;
the v4
init: { ... }nesting is unwrapped with a warning. - Synteny and dotplot rows each take
locanddisplayedRegionNames, in a spec and injb.setSession, so an axis is navigated declaratively.
Calls and what they answer with
State persists between calls in one app run: stash helpers on globalThis. The
open tool and jb.loadSessionSpec replace session, so re-read it per call
and never cache it. open from the start screen loads a new page, emptying
globalThis.
Besides value, a call answers with:
logs, the console output in order. Print intermediate state instead of returning it.notifications, toasts the session raised since the previous call, each with itslevel, each reported once.pageErrors, the throws that got no toast: an uncaught exception, an unawaited rejection, a mobx reaction that died. Nothing else reports these.- a thrown error as its message plus
at code line L, column C, counted in your code. A compile error has no line; look for an unbalanced bracket or anawaitin a non-async callback. - a call that outlives
timeoutMs(default 120 s) answers with an error and the logs so far; the code keeps running withsignalaborted. For a long job, park the promise:
// call 1
globalThis.job = (async () => {
/* minutes of work */
})()
return 'started'
// call 2 (later): await globalThis.jobWaiting on the app
- Model mutations render asynchronously:
await jb.waitReady(30000)after navigating or adding tracks, before reading render state or capturing.jb.mobx.when(() => predicate)awaits any observable condition. - To prove a track drew, pair an empty
notReadywith ajb.getFeaturescount over the visible region. Do not look for pixels: displays render into offscreen canvases, so the page's<canvas>elements measure 0x0. - A freshly created view throws "width undefined" from its region getters until
it mounts and navigates;
await jb.visibleRegions(viewId)waits for both. - Long synchronous loops block the UI thread; chunk big work with
await new Promise(r => setTimeout(r)). - Screenshot when the change is visual and whenever the settle reports
notReadyoroffscreen; for show, hide, reorder, navigate and fit, the settle plusjb.sessionSummary()verifies. Read any image you take. - Whether it all fits is arithmetic:
jb.sessionSummary()reports each view's and display'sheight. A window capture cuts off a taller session;screenshottakesfullPage: true,selector([data-testid="view-container-<view.id>"]) or a measuredrect.
Deep dives
Everything above is the contract. Read one when a task reaches past it.
The model, oriented
// what is open
session.views.map(v => ({ id: v.id, type: v.type }))
// the open view; with several open, jb.view() throws naming them and
// jb.view(id) picks one
const view = jb.view()
// a LinearGenomeView (check v.type — other view types differ)
view.visibleLocStrings // getter: what region is on screen
view.assemblyNames
view.navToLocString('BRCA1') // async; a gene name goes through text search and
// SHOWS the track whose index answered — 4th arg { showHitTrack: false } stops that
await view.launchTrack('mytrack', {}, { height: 300, displayMode: 'compact' })
view.hideTrack('mytrack')
// a shown track's live display model (getters are rich) — find by trackId,
// view.tracks is every shown track; activeDisplay is the one being drawn
const display = jb.trackModel('mytrack').activeDisplay
// open the feature-details panel on a feature you read, as a click would
display.selectFeature(feature)
// the same track drawn by another of its display types, shown or not; a type
// this view cannot draw is refused, naming `compatibleDisplays`, the ones it can
await view.launchTrack(
'mytrack',
{},
{ type: 'LinearMultiSampleVariantDisplay' },
)A variation the display owns is a setting on it: an alignments track has one
LinearAlignmentsDisplay whose read arcs, read cloud and coverage are slots, so
arcs are applyDisplaySettings({ readConnections: 'arc' }) and describeSlots
lists what else it takes.
A display's grammar settings are its plot: display.plot lists them by slot
name, the ones that display declares from one shared vocabulary, and
display.plotKeys names that display's share of it. The common ones are
facet, color, rows, rowColor, unit, y, scales and filter, and a
mark display adds marks and transform. A change is a copy edited and handed
back: structuredClone(display.plot), edited as any object, then
display.plotProblems(draft), which throws what a config file would be refused
for and lists what the display cannot draw, and display.applyPlot(draft). An
object replaces the setting whole and null resets it. A string is the
one-value form, the facet's field or a constant color, and an object spells the
rest out: facet: { field, domain } orders the sections,
color: { field, domain, range } hands the domain values the range colors
in order. A field is a feature attribute, a dotted path such as INFO.SVTYPE,
or strand; an alignments track's facet field is a read dimension
(pairOrientation, mapq, ...) or a tag (tags.HP). The filter is a list of
jexl: expressions, such as ["jexl:feature.type == 'gene'"].
"Advanced → Edit plot..." in the track menu shows a display's plot as text.
A plot of a track's features, written as ggplot2 writes one, is the
LinearMarkDisplay on any feature, alignments, variant or quantitative track:
marks (layers, each a mark with an encoding over feature fields and its
own transform steps), transform, facet, rows and scales, all in its
plot. The same plot applied to another track's mark display draws the same
thing there. The docs topic plots is the whole vocabulary, with the ggplot2
and Vega-Lite name for each idea.
A feature's label is whatever name it has, else its id, and a file decides
which: the hosted RefSeq GFF names a gene by ID and gene_id and has no
Name, so f.get('name') is null there and f.get('id') is the symbol.
Object.keys(f.toJSON()) says what one feature has before you filter on a
field.
- Reads are plain property or getter access; mutations only through actions
(
view.setWidth(800)works,view.width = 800throws). - Snapshots (
jb.mst.getSnapshot(node)) omit computed getters, so read getters off the live node. launchTrackon an already shown track applies the inline settings it was given throughtrack.applyDisplaySettings(settings), so a spec entry or ajb.setSessiondocument naming{ trackId, height }restyles a shown track as well as opening a new one — a view's launch keys work beside its built state, solocon a patched view navigates it. Every route puts each key through the same slot machinery.- A misspelled key on a spec's track entry — one that is neither a config slot
nor something the display has — raises an error notification naming it. A spec
has no return channel of its own, so that is where it reports, rather than
loading the track with the setting missing. A spec
layoutindexes the spec's ownviewsarray. - An action's signature is under
docs topic:"model:<modelType>"section:"Actions"— themodelTypejb.inspectanswered with — config slots by type underdocs topic:"config:BamAdapter", every name underdocs topic:"types". That is where a display's own actions live, for anything a slot does not cover. With the name already in hand, pass the name as thesection—section:"setColor"answers with that one line, where the section holding it is the whole getter or action list, and the page's contents says how many characters that is. - A track too tall for the window wants a height strategy, not
displayMode: a display may takeheightModefitorgrow, andcompactonly shrinks each feature.describeSlotslists both. - The
drawerinjb.sessionSummary()is the widget panel, and it reports the width that panel takes off every view;session.hideAllWidgets()closes it.jb.listTrackscovers connection and hub tracks as well as the session's own.
The fine print on the building helpers, once the one-liners above stop being enough:
- A
notReadyentry'sphaseistooLarge,error,renderError,loading, orcanceledfor a load the user stopped, whichtrack.activeDisplay.reload()retries. applyDisplaySettingsanswers{ applied, unapplied, failed }:failedis a key the display knows and could not set (a wrongly typed value included).unappliedholds{ key, reason }—no-slotfor a key no slot covers, misspellings included, andsetter-onlyfor one the display takes through aset<Key>action, which you call yourself.jb.addViewgoes through the same launcher a spec uses, a ProteinView'sconnectedViewshorthand included.jb.addTracktakessettleMs: 0to skip the settle, for several adds followed by onejb.waitReady.jb.fitToWindowshrinks every display, synteny band and dotplot in proportion to its headroom.- Nested synteny and breakpoint rows count as open views, so they are among the
candidates
jb.viewnames; a container'sviewIdcovers its rows when the container itself cannot answer.
// make every shown track compact
for (const t of session.views.flatMap(v => v.tracks ?? [])) {
t.applyDisplaySettings({ displayMode: 'compact' })
}
return jb.waitReady(30000)session.layoutViews(spec) arranges the views already open into panels without
replacing the session: the same tree as a spec layout (a leaf has views, a
container children and a direction), with a leaf naming view ids from
jb.sessionSummary() or indexes into session.views. It applies the stated
order and returns the ids it seated; the lower-level session.applyLayoutSpec
arranges the panels but leaves each tab's views in their old order. viewIds is
not a key; a node with one throws.
Reading data directly (fast path)
jb.getFeatures asks the same worker the track's display uses, so a shown
track's parsed index is reused, and the features come back as Feature objects.
Reduce and filter in place and return only what you need:
// visible region by default; pass loc for an arbitrary region
const feats = await jb.getFeatures({
trackId: 'volvox_test_vcf',
loc: 'ctgA:1-50,000',
})
// Feature API: f.get('start'), f.get('name'), f.get('type'), f.toJSON()
return {
n: feats.length,
byType: Object.groupBy(feats, f => f.get('type') ?? 'unknown'),
}locis one place: a locstring, or a region object ({ refName, start, end }, whatjb.visibleRegionsanswers). Several places go inregions.assemblyis for a track that names none. A wrong one, or a visible region from a view on another assembly, throws rather than reading the wrong coordinates.- A region over the byte gate throws
region too large for jb.getFeatures, naming the estimate and the limit, rather than answering short. Narrow the region, or passbyteLimitfor a read you mean to be that big. - A base-level quantitative track is one feature per base, so a 160 kb window is
about 160k of them. Reduce with a loop, never
Math.max(...scores), which blows the call stack on an array that size. - The reference sequence is a track too:
jb.listTrackslists each assembly's as aReferenceSequenceTrack, andjb.getFeatureson its trackId answers one feature per region whoseseqholds the bases. - Never
returnthousands of raw features: aggregate, slice, or put them on screen as a track.
For a read of your own, jb.require('@jbrowse/core/util') has parseLocString,
renameRegionsIfNeeded and the getRpcSessionId an rpcManager.call needs;
pass an AbortController's signal in the args to cancel it.
To find out what a remote file holds before adding it as a track, build its
adapter and ask. The adapter cache is a registry module, and getAdapter is
async and answers { dataAdapter }. getRefNames() is on every feature
adapter; getHeader() answers for formats with one (BAM, CRAM, VCF, BED, GFF3)
and null for a bigWig:
const { getAdapter } = jb.require(
'@jbrowse/core/data_adapters/dataAdapterCache',
)
const { dataAdapter } = await getAdapter(pluginManager, 'probe', {
type: 'BigWigAdapter',
bigWigLocation: { uri: url, locationType: 'UriLocation' },
})
return {
refNames: (await dataAdapter.getRefNames()).slice(0, 5),
header: await dataAdapter.getHeader(),
}The probe's adapter lives on the main thread under the sessionId you gave it,
for the life of the page: fine for a header, not for features. jb.getFeatures
does two things raw adapter code gets wrong without an error:
- It renames canonical refNames to the file's spelling with core's
renameRegionsIfNeeded; "ctgA" against a file saying "contigA" matches nothing and reads as "no data here". - It reads on the worker the track's display uses, so the index that display parsed is reused. A main-thread adapter for the same file is a second copy.
An action's argument shape, when the docs have no page for it (a view from a
plugin outside this tree): open a throwaway view, call the action with {} so
the model materializes its defaults, inspect what it created, remove the view:
const probe = session.addView('ProteinView', {})
probe.addStructure({})
const shape = jb.inspect(probe.structures[0])
session.removeView(probe)
return shapeSaving. Desktop writes the session to its .jbrowse file about a second
after every change; rootModel.flushSession() forces it. Web has no save from
jb: the spec you loaded is the record.
Showing something you derived
A track built from values you just computed does not need a file.
FromConfigAdapter holds the features in the track's own config, so the derived
track is part of the session and survives a save and reopen.
session.addSessionTrackConf({
type: 'QuantitativeTrack', // FeatureTrack for non-numeric features
trackId: 'nutlin-log2',
name: 'log2(nutlin / DMSO)',
assemblyNames: ['hg38'],
adapter: {
type: 'FromConfigAdapter',
adapterId: 'nutlin-log2',
features: bins.map((score, i) => ({
uniqueId: 'bin' + i,
refName: 'chr6', // the assembly's canonical spelling
start: start + i * BIN,
end: start + (i + 1) * BIN,
score,
})),
},
})
await jb.view().launchTrack('nutlin-log2')
return jb.waitReady(60000)adapterIdis the adapter cache key; leave it out and the whole feature array is hashed instead.addSessionTrackConfis the destination for a track you stood up on the user's behalf;session.tracksis the site's catalog.- Recomputed the values? Delete, re-add, and change the
adapterId.addSessionTrackConfrefuses a known trackId whose content differs (the same content is idempotent), and the adapter cache is keyed onadapterId, so the same id with new features keeps serving the first array it saw. - Plan for a few thousand features, not more. The array lives in the track
config, so it is held in memory, written into the session snapshot, and
re-serialized by every autosave. Above that, run the tool that does the job
(
bigwigCompare,bedGraphToBigWig, deeptools), write a real indexed file and load that withjb.addTrack.
const old = session.sessionTracks.find(t => t.trackId === 'nutlin-log2')
if (old) {
session.deleteTrackConf(old)
}
session.addSessionTrackConf({
...conf,
adapter: { ...conf.adapter, adapterId: `nutlin-log2-${Date.now()}` },
})
await jb.view().launchTrack('nutlin-log2')Shell tools and files, from Desktop
The machine's tools are one window.require away, which is the route for a
client with no shell of its own. A real pipeline step runs here and its output
loads with jb.addTrack:
const { execFile } = window.require('child_process')
const { promisify } = window.require('util')
const run = promisify(execFile)
const { stdout } = await run('samtools', ['idxstats', '/data/sample.bam'])
return stdout.split('\n').slice(0, 5)A fetch from here has a browser Origin and obeys CORS, and some hosts refuse
it (NCBI's acc.cgi answers 403; eutils does not). window.require('https') or
curl under window.require('child_process') has neither and reads the same
bytes.
In a browser
JBrowse Web publishes window.jb beside window.JBrowseSession and
window.JBrowseRootModel. The differences from Desktop:
- No Node, so no local files.
jb.addTracktakes a URL and refuses a local path rather than adding a track that cannot read. - The data host has to allow the request. A tab is subject to CORS where an Electron app is not, so a file that loads in Desktop may be unreachable from a page.
- A read runs on the thread that draws, so the page stops repainting while a
large
jb.getFeaturesruns. Aggregate in code and return the answer. jb.requireneedsawait jb.ensureRequire()first, once.jb.loadSessionSpecalso rewrites the URL and stores a new session, and the one it replaced is not recoverable from the page. Prefer adding to the open session where that will do.
The Claude in Chrome extension changes the calling convention:
- The value is the last expression. End the snippet with the value, or wrap
the body in
(async () => { ... })(). - One evaluation has a fixed time budget, about 45 seconds, and the code
keeps running when it expires.
jb.loadSessionSpecsettles the new session before it answers, which on a cold hosted config can outlive the budget. Call it on its own, and readjb.sessionSummary()on the next call. - Results are sanitized on the way back. Nested objects are cut off past a
few levels, long strings are clipped, and a string that looks like base64 is
replaced. Return flat, pre-sliced values, or a
JSON.stringifyof what you need. - Its screenshot captures whatever is on screen, with no wait for rendering.
Call
jb.waitReady()first, then screenshot, and readnotReadyfrom the settle result. - Wait for the page. The app assigns
window.jbafter its first render, so poll for it after navigating.