28 Driving sclet from an External Agent
This is the mainline of the AI layer, and the one most users will actually run.
The package can plan an analysis itself if you configure a model provider, and the next chapter covers that. But that path has a cost and a dependency: it needs aisdk, a key, and a networked call for every plan. This chapter needs none of them. You are the planner. You read the shipped skills, write the plan as an R list, and let sclet validate and execute it under exactly the guardrails a built-in model would face.
The division of labour is worth stating precisely, because it is the whole design:
| who | decides |
|---|---|
| you, the planner | what should be analysed, in what order, with which parameters |
sclet |
whether that plan is even legal on this object, and what may be claimed afterwards |
A plan that survives validation is one the engine agreed to execute. That is not the same as good — the skills in inst/skills/ carry the domain knowledge that separates a legal plan from a useful one.
28.1 Read the skills first
sclet ships four skills — plain SKILL.md files under inst/skills/, readable by any agent and by you.
## [1] "sclet-analysis" "sclet-claims" "sclet-driver"
## [4] "sclet-provenance"
## [1] 972
The analysis skill is the one to read before every plan: it encodes stage order, which steps produce evidence other steps need, and which parameters are modelling choices an agent must never make for the user.
28.2 A complete run
The object below is deliberately small so this chapter executes during the book build. Everything here runs offline; no model is contacted at any point.
library(sclet)
set.seed(42)
sce <- scuttle::mockSCE(ncells = 200, ngenes = 80)
sce <- SingleCellExperiment::SingleCellExperiment(
assays = list(counts = SummarizedExperiment::assay(sce, "counts"))
)
dim(sce)## [1] 80 200
28.2.1 1. What can run on this object?
## [1] 34
## [1] "inspect_status" "inspect_ledger"
## [3] "check_qc" "normalize_data"
## [5] "find_variable_features" "scale_data"
## [7] "run_decontx" "run_pca"
Ask for "all" deliberately here — a real plan needs the analysis groups, not just the read group. The registry is object-aware: an action whose prerequisites this object cannot meet is listed together with the reason.
28.2.2 2. Read the ledger, and bind the plan to it
## [1] "sclet-ai-746baa4a"
The fingerprint is not decoration. Validation binds the plan to the state it was written against, so an object edited in between cannot accept a plan written for the old one. That is what makes the stateless pattern in the next section safe.
28.2.3 3. Write the plan
plan <- new_sclet_ai_plan(
task = "pbmc_markers",
context_fingerprint = ledger$fingerprint,
actions = list(
list(id = "s1", action = "normalize_data"),
list(id = "s2", action = "find_variable_features",
params = list(nfeatures = 40L), depends_on = "s1"),
list(id = "s3", action = "scale_data", depends_on = "s2"),
list(id = "s4", action = "run_pca",
params = list(ncomponents = 10L), depends_on = "s3"),
list(id = "s5", action = "find_neighbors",
params = list(dims = 1:10), depends_on = "s4"),
list(id = "s6", action = "find_clusters",
params = list(resolution = 1.0), depends_on = "s5"),
list(id = "s7", action = "run_de_test",
params = list(name = "markers"), depends_on = "s6")
)
)Three rules the engine enforces, so plan to them:
- every
actionis a verbatim registry key — a plausible-looking name is refused - every
paramsvalue matches the declared type ininput_schema - every
${step_id.output.field}reference has a matchingdepends_on
28.2.4 4. Validate — and read the errors
## [1] TRUE
## [1] TRUE
ValidateAIPlan() returns the errors, and unlike an exception they are the useful kind: it names the received value, and for a malformed reference it names the corrected spelling. A refusal at this point is progress — it costs nothing.
28.2.5 5. Dry-run, then execute
ExecuteAIPlan() defaults to dry_run = TRUE, which returns what would run and changes nothing. Non-dry execution requires the confirmation token from this validation object:
## [1] "dry_run"
## [1] "planned" "planned" "planned" "planned" "planned"
## [6] "planned" "planned"
executed <- ExecuteAIPlan(sce, plan, registry = reg, validation = validation,
dry_run = FALSE, confirmation = validation$confirmation_token)
executed$status## [1] "completed"
## [1] "completed" "completed" "completed" "completed"
## [5] "completed" "completed" "completed"
Passing only confirmation without validation makes the engine re-validate and mint a token that can never match. Take both from one fresh validation.
28.2.6 6. Read what the ledger now says
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$id
## [1] "ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$type
## [1] "ai_execution"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$source
## [1] "analysis"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$method
## [1] "ExecuteAIPlan"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$inputs
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$inputs$plan_id
## [1] "plan_pbmc_markers_20261022144927_1jf4n6n3"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$inputs$context_fingerprint
## [1] "sclet-ai-746baa4a"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$inputs$action_ids
## [1] "s1" "s2" "s3" "s4" "s5" "s6" "s7"
##
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$summary
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$summary$status
## [1] "completed"
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$summary$n_actions
## [1] 7
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$summary$n_completed
## [1] 7
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$summary$n_failed
## [1] 0
##
##
## $ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930$created_at
## [1] "2026-10-22 14:49:30.247461"
##
##
## $`preprocess::normalize_logcounts`
## $`preprocess::normalize_logcounts`$id
## [1] "normalize_logcounts"
##
## $`preprocess::normalize_logcounts`$type
## [1] "preprocess"
##
## $`preprocess::normalize_logcounts`$source
## [1] "state"
##
## $`preprocess::normalize_logcounts`$status
## [1] "completed"
##
## $`preprocess::normalize_logcounts`$method
## [1] "scuttle::calculateCPM + log1p"
##
## $`preprocess::normalize_logcounts`$inputs
## $`preprocess::normalize_logcounts`$inputs$assay
## [1] "counts"
##
##
## $`preprocess::normalize_logcounts`$summary
## $`preprocess::normalize_logcounts`$summary$n_cells
## [1] 200
##
## $`preprocess::normalize_logcounts`$summary$n_features
## [1] 80
##
##
## $`preprocess::normalize_logcounts`$created_at
## [1] "2026-10-22 14:49:28.813404"
##
##
## $`reduction::pca`
## $`reduction::pca`$id
## [1] "pca"
##
## $`reduction::pca`$type
## [1] "reduction"
##
## $`reduction::pca`$source
## [1] "state"
##
## $`reduction::pca`$status
## [1] "completed"
##
## $`reduction::pca`$method
## [1] "scater::runPCA"
##
## $`reduction::pca`$inputs
## $`reduction::pca`$inputs$layer
## [1] "scaled"
##
## $`reduction::pca`$inputs$assay
## [1] "scaled"
##
## $`reduction::pca`$inputs$subset_row
## NULL
##
## $`reduction::pca`$inputs$integration
## NULL
##
##
## $`reduction::pca`$summary
## $`reduction::pca`$summary$n_components
## [1] 10
##
##
## $`reduction::pca`$created_at
## [1] "2026-10-22 14:49:29.334175"
##
##
## $`graph::knn_graph`
## $`graph::knn_graph`$id
## [1] "knn_graph"
##
## $`graph::knn_graph`$type
## [1] "graph"
##
## $`graph::knn_graph`$source
## [1] "state"
##
## $`graph::knn_graph`$status
## [1] "completed"
##
## $`graph::knn_graph`$method
## [1] "bluster::makeSNNGraph"
##
## $`graph::knn_graph`$inputs
## $`graph::knn_graph`$inputs$dims
## [1] 1 2 3 4 5 6 7 8 9 10
##
## $`graph::knn_graph`$inputs$reduction
## [1] "PCA"
##
## $`graph::knn_graph`$inputs$layer
## [1] "scaled"
##
## $`graph::knn_graph`$inputs$integration
## NULL
##
##
## $`graph::knn_graph`$summary
## $`graph::knn_graph`$summary$n_vertices
## [1] 200
##
## $`graph::knn_graph`$summary$n_edges
## [1] 8985
##
##
## $`graph::knn_graph`$created_at
## [1] "2026-10-22 14:49:29.468232"
##
##
## $`clustering::louvain_clusters`
## $`clustering::louvain_clusters`$id
## [1] "louvain_clusters"
##
## $`clustering::louvain_clusters`$type
## [1] "clustering"
##
## $`clustering::louvain_clusters`$source
## [1] "state"
##
## $`clustering::louvain_clusters`$status
## [1] "completed"
##
## $`clustering::louvain_clusters`$method
## [1] "igraph::cluster_louvain"
##
## $`clustering::louvain_clusters`$inputs
## $`clustering::louvain_clusters`$inputs$graph
## [1] "knn_graph"
##
## $`clustering::louvain_clusters`$inputs$reduction
## [1] "PCA"
##
## $`clustering::louvain_clusters`$inputs$layer
## [1] "scaled"
##
## $`clustering::louvain_clusters`$inputs$integration
## NULL
##
##
## $`clustering::louvain_clusters`$summary
## $`clustering::louvain_clusters`$summary$n_clusters
## [1] 4
##
##
## $`clustering::louvain_clusters`$created_at
## [1] "2026-10-22 14:49:29.586603"
##
##
## $`detest::markers`
## $`detest::markers`$id
## [1] "markers"
##
## $`detest::markers`$type
## [1] "detest"
##
## $`detest::markers`$source
## [1] "state"
##
## $`detest::markers`$status
## [1] "completed"
##
## $`detest::markers`$method
## [1] "FindAllMarkers"
##
## $`detest::markers`$inputs
## $`detest::markers`$inputs$ident.1
## NULL
##
## $`detest::markers`$inputs$ident.2
## NULL
##
## $`detest::markers`$inputs$active_ident
## [1] "colLabels"
##
##
## $`detest::markers`$summary
## $`detest::markers`$summary$n_markers
## [1] 48
##
##
## $`detest::markers`$created_at
## [1] "2026-10-22 14:49:29.714358"
##
##
## $`ai_evidence::ev:findallmarkers`
## $`ai_evidence::ev:findallmarkers`$id
## [1] "ev:findallmarkers"
##
## $`ai_evidence::ev:findallmarkers`$type
## [1] "ai_evidence"
##
## $`ai_evidence::ev:findallmarkers`$source
## [1] "state"
##
## $`ai_evidence::ev:findallmarkers`$status
## [1] "completed"
##
## $`ai_evidence::ev:findallmarkers`$method
## [1] "sclet_ai_evidence"
##
## $`ai_evidence::ev:findallmarkers`$inputs
## $`ai_evidence::ev:findallmarkers`$inputs$source
## [1] "markers"
##
## $`ai_evidence::ev:findallmarkers`$inputs$parents
## [1] "markers"
##
##
## $`ai_evidence::ev:findallmarkers`$summary
## $`ai_evidence::ev:findallmarkers`$summary$id
## [1] "ev:findallmarkers"
##
## $`ai_evidence::ev:findallmarkers`$summary$kind
## [1] "deterministic_summary"
##
## $`ai_evidence::ev:findallmarkers`$summary$values
## $`ai_evidence::ev:findallmarkers`$summary$values$n_rows
## [1] 48
##
## $`ai_evidence::ev:findallmarkers`$summary$values$n_upregulated
## [1] 23
##
## $`ai_evidence::ev:findallmarkers`$summary$values$n_groups
## [1] 4
##
## $`ai_evidence::ev:findallmarkers`$summary$values$smallest_group_n
## [1] 28
##
## $`ai_evidence::ev:findallmarkers`$summary$values$pvalue_threshold
## [1] 0.05
##
## $`ai_evidence::ev:findallmarkers`$summary$values$logfc_threshold
## [1] 0.1
##
## $`ai_evidence::ev:findallmarkers`$summary$values$min_pct
## [1] 0.01
##
## $`ai_evidence::ev:findallmarkers`$summary$values$raw_values_included
## [1] FALSE
##
## $`ai_evidence::ev:findallmarkers`$summary$values$n_top_up
## [1] 10
##
##
## $`ai_evidence::ev:findallmarkers`$summary$claim_level
## [1] "associated"
##
## $`ai_evidence::ev:findallmarkers`$summary$source
## [1] "markers"
##
## $`ai_evidence::ev:findallmarkers`$summary$parents
## [1] "markers"
##
## $`ai_evidence::ev:findallmarkers`$summary$scope
## list()
##
## $`ai_evidence::ev:findallmarkers`$summary$object_fingerprint
## [1] "sclet-ai-15886aaf"
##
## $`ai_evidence::ev:findallmarkers`$summary$status
## [1] "completed"
##
## $`ai_evidence::ev:findallmarkers`$summary$created_at
## [1] "2026-10-22 14:49:30.139109"
##
## $`ai_evidence::ev:findallmarkers`$summary$dependency_group
## [1] "90eae04d1a8f8d028b3c41efb055ffc79395fb6b0f39e13551ecfac918cec000"
##
##
## $`ai_evidence::ev:findallmarkers`$created_at
## [1] "2026-10-22 14:49:30.139496"
The analysis records, the types the handlers were allowed to write, and the execution record itself are all on the object. This is the audit trail, and it is what the last chapter of this part is about.
28.3 Advancing in rounds
ExecuteAIPlan() walks the plan in order with per-step retry. Nothing in execution rewrites later steps. A multi-stage analysis is therefore a sequence of plans, and starting each one is your decision.
The reason is measured, not stylistic: recording a round changes the object’s fingerprint, so the previous plan is refused afterwards by design.
## [1] FALSE
## [1] "plan context fingerprint does not match the current object"
An old plan not being reusable is the feature: the plan you checked described a different object than the one you now hold.
Two rules follow from the round boundary:
- A plan may only reference steps of its own plan.
${old_step.output.detest_id}across a round is refused. Carry a value forward by naming the record that produced it —detest_id = "markers"— which the ledger in front of you supplies literally. - Re-validate every time. The fingerprint binds the plan to the object state it was validated against.
Start a new round when a stage finished and the next feeds on it, when a step failed for a reason that changes the route, when the plan never covered the goal (plan$metadata$goal_coverage$missing), or when the user answered an open choice.
28.4 What you can do that a model cannot
The registry has two audiences. A model is shown the actions it can actually fill; a few analyses need an input that exists only on your side — a gene set, a bulk phenotype matrix, a reference SingleCellExperiment, a GRN prior, a pySCENIC database. Those actions are registered and executable, but they are marked planner_visible = FALSE and kept out of a model’s catalog, because a plan proposing one of them would fail on a parameter no model can produce.
Writing the plan yourself, you can use them. include = "all" means “every action this package offers”, and the difference from what a planner sees is exactly this list.
all_actions <- AIDefaultExecutionRegistry(sce, include = "all")
driver_only <- names(all_actions)[
!vapply(all_actions, function(a) isTRUE(a$planner_visible), logical(1L))
]
driver_only## [1] "map_reference_knn"
## [2] "map_reference_symphony"
## [3] "run_regvelo"
## [4] "run_geneset_scoring"
## [5] "run_supercell"
## [6] "run_phenotype_association"
## [7] "run_scenic"
## [8] "run_in_silico_perturbation"
## [9] "run_spatial_deconvolution"
Each one names, in its input_schema, what you have to bring. That is not a courtesy: ValidateAIPlan() refuses the step without it, and the refusal names the parameter rather than the action.
28.5 The plan you ran is a graph, and it outlives the session
A round is not a list of steps in a log file. Each step that writes an analysis record is attributed to the record it produced, and steps that feed other steps keep the edge the plan declared. AnalysisGraph() projects that onto two tables you can read, print, or hand to another agent.
## <sclet trace graph> sclet-trace-graph/1
## rounds: 1 (1 without stored topology)
## nodes: 14 | edges: 10
## from
## 1 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s1
## 2 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s2
## 3 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s3
## 4 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s4
## 5 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s5
## 6 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s6
## 7 step:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930:s7
## 8 record:clustering::louvain_clusters
## to
## 1 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 2 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 3 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 4 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 5 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 6 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 7 round:ai_execution_plan_pbmc_markers_20261022144927_1jf4n6n3_20261022144930
## 8 record:graph::knn_graph
## kind parent_kind declared_input
## 1 step_of <NA> <NA>
## 2 step_of <NA> <NA>
## 3 step_of <NA> <NA>
## 4 step_of <NA> <NA>
## 5 step_of <NA> <NA>
## 6 step_of <NA> <NA>
## 7 step_of <NA> <NA>
## 8 consumed <NA> graph
## [1] 7
Two questions this answers that a printed log cannot: which step produced a named record, and which later steps declared a given step as upstream.
# Which step produced the marker table this round registered?
marker_record <- graph$edges$to[graph$edges$kind == "produced" &
grepl("detest", graph$edges$to)]
graph_producers(graph, marker_record[1])## $ids
## character(0)
##
## $complete
## [1] FALSE
# Which later steps declared the round's first step as upstream?
first_step <- graph$nodes$id[graph$nodes$kind == "step"][1]
graph_consumers(graph, first_step)## $ids
## character(0)
##
## $complete
## [1] FALSE
Both queries return the answer and whether the round that produced it carries a full trace. A record with no producer edge is not an error to chase: it may simply predate the trace, or have been written outside a plan.
The graph is a projection over the object’s own records, so it survives saveRDS() with them, and a round that ran before this book existed is still readable — it simply reports complete = FALSE for that round, because the topology was never stored for it.
Two things it will not show, and both are recorded rather than hidden. A re-run that re-writes an existing record id appears as a refreshed edge from the newer step, not as a second copy of the record, because the record store overwrites by id. And an action that returns a different object — metacell aggregation is the one — takes the earlier records with it; the graph says so with meta$object_replaced instead of silently dropping the rounds.
28.6 Resuming across processes
The ledger lives on the object, so the whole analysis survives saveRDS(). An agent does not need a resident R session: each interaction can be a fresh Rscript that reads the object, plans the next stage against the fingerprint it carries, executes, and saves it back. Because is.character(validation$plan_id) — the id in the validation object — must match, and the fingerprint must still be current, a stale plan is refused rather than silently run.
28.7 Things not to attempt
- An empty plan is not a plan. Zero actions are refused (
plan_empty) instead of reportingstatus = "completed"with no results. If the goal needs no computation, answer directly. - A plan has no conditional steps. “If this succeeds, also do X” is a second round, not a longer plan.
- Never claim without evidence. The report’s claims are capped by the evidence that supports them, which is the subject of evidence governance.
RunAIPlan()is a convenience, not a different contract. It validates internally, so use it for the common case andValidateAIPlan()/ExecuteAIPlan()separately when you need to inspect the validation object — for instance to present a refusal to a user before fixing it.