diff --git a/.gitignore b/.gitignore
index 372e6a0f..18965bfb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@
.quarto
docs
.claude/settings.local.json
+_dev
diff --git a/DESCRIPTION b/DESCRIPTION
index b8b29507..e9d3ff5f 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -88,6 +88,7 @@ Collate:
'btw.R'
'btw_client.R'
'btw_client_app.R'
+ 'btw_task.R'
'btw_this.R'
'clipboard.R'
'deprecated.R'
diff --git a/NAMESPACE b/NAMESPACE
index 746fa91c..948d1884 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -26,6 +26,7 @@ export(btw_app)
export(btw_client)
export(btw_mcp_server)
export(btw_mcp_session)
+export(btw_task)
export(btw_task_create_btw_md)
export(btw_task_create_readme)
export(btw_this)
diff --git a/NEWS.md b/NEWS.md
index fff0de9e..7e03896c 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,13 @@
# btw (development version)
+* New `btw_task()` function runs pre-formatted LLM tasks defined in markdown
+ files with YAML frontmatter. Task files support template variable
+ interpolation via `{{ variable }}` syntax, optional client and tool
+ configuration, and all four execution modes (`"app"`, `"console"`,
+ `"client"`, `"tool"`). Task files can also specify `title`, `icon`,
+ `description`, and `name` fields in their YAML frontmatter to customize
+ the tool definition when used in `"tool"` mode (#42).
+
* New `btw_tool_files_edit()` tool makes targeted, validated line-based edits
to files using `replace`, `insert_after`, and `replace_range` actions. Edits
are anchored to content hashes, so stale edits are rejected if the file has
diff --git a/R/btw_task.R b/R/btw_task.R
new file mode 100644
index 00000000..aee7db7e
--- /dev/null
+++ b/R/btw_task.R
@@ -0,0 +1,279 @@
+#' Run a pre-formatted btw task
+#'
+#' @description
+#' Runs a btw task defined in a file with YAML frontmatter configuration and
+#' a markdown body containing the task prompt. The task file format is similar
+#' to `btw.md` files, with client and tool configuration in the frontmatter and
+#' the task instructions in the body.
+#'
+#' ## Task File Format
+#'
+#' Task files use the same format as `btw.md` files:
+#'
+#' ```yaml
+#' ---
+#' client:
+#' provider: anthropic
+#' model: claude-sonnet-4
+#' tools: [docs, files]
+#' ---
+#'
+#' Your task prompt here with {{ variable }} interpolation...
+#' ```
+#'
+#' ## Template Variables
+#'
+#' The task prompt body supports template variable interpolation using
+#' `{{ variable }}` syntax via [ellmer::interpolate()]. Pass named arguments
+#' to provide values for template variables:
+#'
+#' ```r
+#' btw_task("my-task.md", package_name = "dplyr", version = "1.1.0")
+#' ```
+#'
+#' ## Additional Context
+#'
+#' Unnamed arguments are treated as additional context and converted to text
+#' using [btw()]. This context is appended to the system prompt:
+#'
+#' ```r
+#' btw_task("analyze.md", dataset_name = "mtcars", mtcars, my_function)
+#' # ^-- template var ^-- additional context
+#' ```
+#'
+#' @param path Path to the task file containing YAML configuration and prompt.
+#' @param ... Named arguments become template variables for interpolation in the
+#' task prompt. Unnamed arguments are treated as additional context objects
+#' and converted to text via [btw()].
+#' @param client An [ellmer::Chat] client to override the task file's client
+#' configuration. If `NULL`, uses the client specified in the task file's
+#' YAML frontmatter, falling back to the default client resolution of
+#' [btw_client()].
+#' @param mode The execution mode for the task:
+#' - `"app"`: Launch interactive Shiny app (default)
+#' - `"console"`: Interactive console chat with [ellmer::live_console()]
+#' - `"client"`: Return configured [ellmer::Chat] client without running
+#' - `"tool"`: Return an [ellmer::tool()] object for programmatic use
+#'
+#' @return Depending on `mode`:
+#' - `"app"`: Returns the chat client invisibly after launching the app
+#' - `"console"`: Returns the chat client after console interaction
+#' - `"client"`: Returns the configured chat client
+#' - `"tool"`: Returns an [ellmer::tool()] object
+#'
+#' @examples
+#' # Create a simple task file
+#' tmp_task_file <- tempfile(fileext = ".md")
+#'
+#' cat(file = tmp_task_file, '---
+#' client: anthropic/claude-sonnet-4-6
+#' tools: [docs, files]
+#' ---
+#'
+#' Analyze the {{ package_name }} package and create a summary.
+#' ')
+#'
+#' # Task with template interpolation
+#' btw_task(tmp_task_file, package_name = "dplyr", mode = "tool")
+#'
+#' # Include additional context
+#' btw_task(
+#' tmp_task_file,
+#' package_name = "ggplot2",
+#' mtcars, # Additional context
+#' mode = "tool"
+#' )
+#'
+#' @family task and agent functions
+#' @export
+btw_task <- function(
+ path,
+ ...,
+ client = NULL,
+ mode = c("app", "console", "client", "tool")
+) {
+ check_string(path)
+ mode <- arg_match(mode)
+
+ if (!fs::file_exists(path)) {
+ cli::cli_abort("Task file not found: {.path {path}}")
+ }
+
+ task_config <- read_single_btw_file(path)
+ task_prompt <- task_config$btw_system_prompt
+ task_config$btw_system_prompt <- NULL
+
+ if (is.null(task_prompt) || !nzchar(task_prompt)) {
+ cli::cli_abort(
+ "Task file must contain a prompt in the body: {.path {path}}"
+ )
+ }
+
+ # Capture dots as quosures to preserve expressions (needed for btw() naming)
+ all_quos <- enquos(...)
+ quo_names <- names2(all_quos)
+
+ # Named quos → template variables (evaluated); unnamed quos → context objects
+ template_vars <- lapply(all_quos[nzchar(quo_names)], eval_tidy)
+ context_quos <- all_quos[!nzchar(quo_names)]
+
+ # Interpolate template variables in the task prompt
+ if (length(template_vars) > 0) {
+ task_prompt <- ellmer::interpolate(task_prompt, !!!template_vars)
+ }
+
+ # Build final system prompt: task body + optional additional context
+ final_system_prompt <- task_prompt
+
+ if (length(context_quos) > 0) {
+ context_values <- lapply(context_quos, eval_tidy)
+ context_names <- vapply(context_quos, as_label, character(1))
+ names(context_values) <- context_names
+ context_env <- new_environment(context_values, parent = parent.frame())
+ user_context_text <- trimws(paste(
+ btw_this(context_env, items = context_names),
+ collapse = "\n"
+ ))
+ if (nzchar(user_context_text)) {
+ final_system_prompt <- paste(
+ c(final_system_prompt, "", "---", "# Additional Context", "", user_context_text),
+ collapse = "\n"
+ )
+ }
+ }
+
+ # Resolve client: explicit arg > task file YAML > btw defaults
+ if (is.null(client) && !is.null(task_config$client)) {
+ client <- task_config$client
+ }
+
+ tools <- task_config$tools %||% btw_tools()
+
+ # Call btw_client() to register tools, then replace the system prompt
+ # with the task-specific content (matches btw_task_create_* pattern)
+ chat_client <- btw_client(
+ client = client,
+ tools = tools
+ )
+ chat_client$set_system_prompt(final_system_prompt)
+
+ # Derive identity fields from task config or file basename
+ tool_name_raw <- task_config$name %||% fs::path_ext_remove(basename(path))
+ tool_name_normalized <- validate_task_tool_name(tool_name_raw, path)
+ display_name <- task_config$title %||%
+ to_title_case(gsub("[_-]", " ", tool_name_raw))
+
+ if (mode == "client") {
+ return(chat_client)
+ }
+
+ if (mode == "tool") {
+ task_tool_fn <- function(prompt = "") {
+ this_client <- chat_client$clone()
+
+ sys_prompt <- paste0(
+ this_client$get_system_prompt(),
+ "\n\n---\n\n",
+ "YOU ARE NOW OPERATING IN TOOL MODE. ",
+ "The user cannot respond directly to you. ",
+ "Because you cannot talk to the user, you will need to make ",
+ "your own decisions using the information available to you ",
+ "and the best of your abilities. ",
+ "You may do additional exploration if needed."
+ )
+
+ this_client$set_system_prompt(sys_prompt)
+
+ if (nzchar(prompt)) {
+ this_client$chat(paste0("Additional instructions: ", prompt))
+ } else {
+ this_client$chat("Please complete the task as instructed.")
+ }
+ }
+
+ description <- task_config$description %||% {
+ lines <- strsplit(task_prompt, "\n")[[1]]
+ first_nonempty <- trimws(lines[nzchar(trimws(lines))])
+ if (length(first_nonempty) > 0) {
+ gsub("^#+\\s*", "", first_nonempty[1])
+ } else {
+ paste0("Run the task defined in ", basename(path))
+ }
+ }
+
+ tool <- ellmer::tool(
+ task_tool_fn,
+ name = paste0("btw_task_", tool_name_normalized),
+ description = description,
+ annotations = ellmer::tool_annotations(
+ title = display_name,
+ read_only_hint = FALSE,
+ open_world_hint = TRUE
+ ),
+ arguments = list(
+ prompt = ellmer::type_string(
+ "Additional instructions for the task. Leave empty to proceed with default instructions.",
+ required = FALSE
+ )
+ )
+ )
+
+ if (!is.null(task_config$icon)) {
+ icon <- custom_icon(task_config$icon)
+ if (is.null(icon)) {
+ cli::cli_warn(c(
+ "Invalid icon in task file {.path {path}}",
+ "i" = "Ignoring {.field icon}: {.val {task_config$icon}}"
+ ))
+ }
+ tool@annotations$icon <- icon
+ }
+
+ return(tool)
+ }
+
+ if (mode == "console") {
+ cli::cli_text(
+ "Starting {.strong btw_task()} for {.file {display_name}} in live console mode."
+ )
+ cli::cli_text(
+ "{cli::col_yellow(cli::symbol$play)} ",
+ "Say \"{.strong {cli::col_magenta('Let\\'s get started.')}}\" to begin."
+ )
+ ellmer::live_console(chat_client)
+ } else {
+ btw_app_from_client(
+ client = chat_client,
+ messages = list(list(
+ role = "assistant",
+ content = paste0(
+ "\U1F4CB Hi! I'm ready to help with the ",
+ htmltools::htmlEscape(display_name),
+ " task.
",
+ "Say Let's get started. to begin."
+ )
+ ))
+ )
+ }
+}
+
+validate_task_tool_name <- function(name, path) {
+ check_string(name)
+
+ name <- trimws(name)
+ if (!nzchar(name)) {
+ cli::cli_abort(c(
+ "Task name cannot be empty: {.path {path}}",
+ "i" = "Provide a non-empty {.field name} in YAML frontmatter or use a file name with letters."
+ ))
+ }
+
+ if (!grepl("^[a-zA-Z0-9_-]+$", name)) {
+ cli::cli_abort(c(
+ "Invalid task name {.val {name}} in {.path {path}}",
+ "i" = "{.field name} must contain only letters, numbers, - and _."
+ ))
+ }
+
+ name
+}
diff --git a/man/btw_agent_tool.Rd b/man/btw_agent_tool.Rd
index e2b3c600..0e3aa89b 100644
--- a/man/btw_agent_tool.Rd
+++ b/man/btw_agent_tool.Rd
@@ -65,7 +65,7 @@ from other icon packages. Supported packages:\tabular{lll}{
bsicons \tab \code{bsicons::house} \tab \code{\link[bsicons:bs_icon]{bsicons::bs_icon()}} \cr
phosphoricons \tab \code{phosphoricons::house} \tab \code{\link[phosphoricons:ph]{phosphoricons::ph()}} \cr
rheroicons \tab \code{rheroicons::home} \tab \code{\link[rheroicons:rheroicon]{rheroicons::rheroicon()}} \cr
- tabler \tab \code{tabler::home} \tab \code{\link[tabler:tabler-components]{tabler::icon()}} \cr
+ tabler \tab \code{tabler::home} \tab \code{\link[tabler:icon]{tabler::icon()}} \cr
shiny \tab \code{shiny::home} \tab \code{\link[shiny:icon]{shiny::icon()}} \cr
}
diff --git a/man/btw_task.Rd b/man/btw_task.Rd
new file mode 100644
index 00000000..a173d23d
--- /dev/null
+++ b/man/btw_task.Rd
@@ -0,0 +1,112 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/btw_task.R
+\name{btw_task}
+\alias{btw_task}
+\title{Run a pre-formatted btw task}
+\usage{
+btw_task(
+ path,
+ ...,
+ client = NULL,
+ mode = c("app", "console", "client", "tool")
+)
+}
+\arguments{
+\item{path}{Path to the task file containing YAML configuration and prompt.}
+
+\item{...}{Named arguments become template variables for interpolation in the
+task prompt. Unnamed arguments are treated as additional context objects
+and converted to text via \code{\link[=btw]{btw()}}.}
+
+\item{client}{An \link[ellmer:Chat]{ellmer::Chat} client to override the task file's client
+configuration. If \code{NULL}, uses the client specified in the task file's
+YAML frontmatter, falling back to the default client resolution of
+\code{\link[=btw_client]{btw_client()}}.}
+
+\item{mode}{The execution mode for the task:
+\itemize{
+\item \code{"app"}: Launch interactive Shiny app (default)
+\item \code{"console"}: Interactive console chat with \code{\link[ellmer:live_console]{ellmer::live_console()}}
+\item \code{"client"}: Return configured \link[ellmer:Chat]{ellmer::Chat} client without running
+\item \code{"tool"}: Return an \code{\link[ellmer:tool]{ellmer::tool()}} object for programmatic use
+}}
+}
+\value{
+Depending on \code{mode}:
+\itemize{
+\item \code{"app"}: Returns the chat client invisibly after launching the app
+\item \code{"console"}: Returns the chat client after console interaction
+\item \code{"client"}: Returns the configured chat client
+\item \code{"tool"}: Returns an \code{\link[ellmer:tool]{ellmer::tool()}} object
+}
+}
+\description{
+Runs a btw task defined in a file with YAML frontmatter configuration and
+a markdown body containing the task prompt. The task file format is similar
+to \code{btw.md} files, with client and tool configuration in the frontmatter and
+the task instructions in the body.
+\subsection{Task File Format}{
+
+Task files use the same format as \code{btw.md} files:
+
+\if{html}{\out{