26 AI in One Call

This chapter is the shortest path from an installed package to an AI-assisted analysis. It is written for someone who has never used the AI layer and does not yet care how it works. Read it once, run the two commands, and come back for the rest of the part when you want to know what happened.

Everything here is real: no chunk is \dontrun, and the ones that call a model are gated the same way as the rest of the book, so on a machine with no provider they are skipped rather than failed.

26.1 What you need first

The AI layer is optional. One package, installed once:

# Not executed: this is a machine-level action, not a chunk.
install.packages("aisdk")

scletAI() checks this before anything is sent, so a missing package is an error that names the command rather than a plan that reaches a provider and fails there.

And one provider key in ~/.Renviron, which R reads at startup (restart afterwards):

# Not executed: never print a key into a rendered book.
#   OPENAI_API_KEY=sk-...

sclet never asks for the key and never sees it: aisdk reads it from the environment and the model provider bills the call. A call is also non-deterministic — the same goal can produce a different plan — which is why the layer records what it did rather than trusting what it said.

26.2 The one call

# `conflicted` refuses a `library()` while a name exists in several attached
# packages. The book's setup chunk prefers base's `intersect`/`union`/`setdiff`
# BEFORE the later chapters attach GenomicRanges (via SVP), and a preference
# does not apply retroactively to a package attached after it was set -- so it
# is set again here, where the AI path begins. Same choice, one line.
for (fn in c("intersect", "union", "setdiff")) {
    try(conflicted::conflicts_prefer(fn, base::get(fn), .quiet = TRUE), silent = TRUE)
}
set.seed(42)
sce <- scuttle::mockSCE(ncells = 200, ngenes = 80)
sce <- SingleCellExperiment::SingleCellExperiment(
    assays = list(counts = SummarizedExperiment::assay(sce, "counts")),
    colData = S4Vectors::DataFrame(SummarizedExperiment::colData(sce))
)
sce <- NormalizeData(sce)
sce <- FindVariableFeatures(sce)
sce <- ScaleData(sce)
dim(sce)
## [1]  80 200

Now describe what you want. scletAI() is the whole API:

analysis <- scletAI(sce, "cluster the cells and find markers for each cluster")
analysis
## sclet beginner AI analysis [invalid_plan]
## Goal: cluster the cells and find markers for each cluster

The plan is printed, then the session asks [y/N]. In a script, pass confirm = "yes" to run without prompting, or confirm = "no" to see the plan and stop. On a machine with no provider this chunk is skipped by the same switch as the rest of the AI material, and the chapter still renders.

26.2.1 What came back, and what it means

analysis$status is the one field to read. Three outcomes, all of them normal:

status what happened what to do
completed every action ran and was recorded read analysis$object — the clusters are on it
dry_run the plan was previewed, not executed (you said no, or the session is non-interactive) run again with confirm = "yes"
invalid_plan the engine refused the plan read analysis$report$errors; each names what to fix

The refusal case is not a bug and not a misconfiguration: a language model proposes a plan, the engine checks it against the live object, and a plan that does not survive is refused before anything runs. That is the design — the alternative is an analysis whose steps were never checked. It is also non-deterministic: the same goal on the same object can propose a different plan on a different day, so a refusal is a retry, not a verdict.

analysis$status
## [1] "invalid_plan"

When it completes, the updated SingleCellExperiment is in analysis$object — the clusters and marker table are on it, exactly as if you had called FindClusters() and RunDEtest() yourself:

if (identical(analysis$status, "completed")) {
    length(unique(as.character(analysis$object$ident)))
} else {
    "no clusters: the plan did not complete"
}
## [1] "no clusters: the plan did not complete"

26.3 What just happened

scletAI() is one call, but it is not one mechanism. Underneath, in order:

  1. A plan was composed. A language model was given the object’s recorded state — what analyses exist, what assays are active, what is blocked — and asked for steps. It was never given your expression matrix.
  2. The plan was validated against the live object. Every action had to be a real registry key, every parameter a real one for it, every dependency satisfiable. An illegal plan is refused here, before anything runs.
  3. You approved it. Confirmation is a token minted for that exact plan and object state; a plan edited afterwards is refused rather than silently run.
  4. It executed one action at a time, each result recorded as an evidence node.
  5. A report was rendered from the ledger and re-checked — which is why the numbers in it can be recomputed rather than retold.

The rest of this part is those five steps in detail, plus the route where you write the plan yourself.

26.4 The three other calls you will meet

scletAI() is the door; these are the rooms, in the order a user meets them.

26.4.1 Ask it something, without running anything

AskAI(sce, "What has been analysed so far, and what should I check next?")
## sclet AI result [copilot]
## Only one recorded analysis exists in the ledger: `preprocess::normalize_logcounts` (status `completed`, method `scuttle::calculateCPM + log1p`, inputs `counts`, scale factor 10000, 200 cells × 80 features), recorded as a `state` record with no parents (root of the recorded chain). A `scaled` assay/layer exists and is the active view with `last_command = "ScaleData"`, but no `ScaleData` record appears in `analyses`, `state_records`, `workflows`, or the timeline (timeline `n_steps = 1`), so scaling is visible in the object but undocumented in the ledger. `features.hvg` is reported (n = 2000, method `scran`, rowData columns mean, total, tech, bio, p.value, FDR), which conflicts with the 80-gene matrix and should be treated as unverified. Not present: reductions (`reductions = []`), graphs, clustering/identity levels (`n_levels = 0`), marker/DE tests, and any `ev:` evidence nodes. Blocked gates: trajectory (`reduction_missing`), differential abundance (`design_role_missing`, needs `ConfirmAIDesignSemantics()`), velocity (`spliced_unspliced_missing`), spatial (no spatial record), multimodal (no altExp). Design semantics are unconfirmed; `Treatment` is only a candidate condition column. The ambient-RNA screen label `low` (low-UMI tail share 0.0718, top-gene detection uniformity 0.7271) is a screen, not a measurement — no contamination estimate exists absent a `run_decontx` evidence node. `execute_analysis` capability is unknown and `available_actions` is empty. No causal or biological claims are supported. Recommended order: reconcile record vs object (scaled layer, HVG provenance), confirm design semantics, run descriptive QC spine on counts/logcounts, then run_pca → find_neighbors (→ optional clustering), then marker tests within clusters and condition contrasts only after design confirmation; optionally run_decontx if ambient contamination is suspected.
## Findings:10 Recommendations:6 Proposed actions:6

AskAI() is read-only: it answers from the recorded state and cannot change the object. It is the right call when you want to understand what you have before deciding what to do.

26.4.2 The same run with the switches visible

RunAIAnalysis(sce, goal = "cluster the cells", confirm = "yes")
## sclet beginner AI analysis [completed]
## Goal: cluster the cells
## Actions: run_pca -> find_neighbors -> find_clusters
## Report self-check: passed

RunAIAnalysis() is the engine scletAI() calls, with its arguments exposed: include to choose which action groups the planner may use, record to decide whether the round is written to the ledger, render_report and verify_before_continue for the self-check. Reach for it when the defaults are not what you want — not first.

26.4.3 Read a finished round back

graph <- AnalysisGraph(analysis$object)
graph
## <sclet trace graph> sclet-trace-graph/1 
##   rounds: 0  
##   nodes: 1 | edges: 0

AnalysisGraph() projects the round onto two tables: which step produced which record, and which step consumed which earlier step’s output. It is how you audit a run you did not write, and how an agent picks up where the last one stopped.

26.5 When it refuses

Refusal is the layer working, not failing. The three you will meet first:

what you see what it means
sclet_ai_missing_dependency aisdk is not installed; the message names the command
sclet_ai_missing_model no model could be resolved; supply one, or set a default
plan_empty / action is not registered the model proposed nothing, or something that is not an action — the engine refused rather than guessing

A provider error (a 401, a rate limit) is the provider’s, and arrives verbatim; sclet does not translate it.