Skip to contents

Runtime hooks customize generated wrappers before the request helper runs and after it returns. Use them to normalize identifiers, construct service-specific payloads, skip requests, or shape results. Your client owns the executor and the hook functions. Installing the client does not require specmill.

This guide uses the development version of specmill with configure_hooks(). Follow installation and package setup first. An older installed version may support hook configuration without providing this setup command. The examples below run offline when this article is built.

Create the optional executor

For a new client, initialize the package first. For an existing package, use its root and proceed directly to configure_hooks().

schema <- system.file('catalogue/schema.json', package = 'specmill', mustWork = TRUE)
root <- tempfile('hook-client-')
specmill::initialize_client(
  root, schema, package = 'hookclient', title = 'Hook Example Client',
  author = list(given = 'Example', family = 'Maintainer', email = 'you@example.org'),
  license = 'MIT + file LICENSE', base_url = 'https://example.invalid'
)
#> [[1]]
#> [[1]]$file
#> [1] "schema/openapi.json"
#> 
#> [[1]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/schema/openapi.json"
#> 
#> [[1]]$action
#> [1] "write"
#> 
#> 
#> [[2]]
#> [[2]]$file
#> [1] "specmill.yml"
#> 
#> [[2]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/specmill.yml"
#> 
#> [[2]]$action
#> [1] "write"
#> 
#> 
#> [[3]]
#> [[3]]$file
#> [1] "apis/default.yml"
#> 
#> [[3]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/apis/default.yml"
#> 
#> [[3]]$action
#> [1] "write"
#> 
#> 
#> [[4]]
#> [[4]]$file
#> [1] "R/api_request.R"
#> 
#> [[4]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/R/api_request.R"
#> 
#> [[4]]$action
#> [1] "write"
#> 
#> 
#> [[5]]
#> [[5]]$file
#> [1] "R/api_options.R"
#> 
#> [[5]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/R/api_options.R"
#> 
#> [[5]]$action
#> [1] "write"
#> 
#> 
#> [[6]]
#> [[6]]$file
#> [1] ".specmill/helpers/api_request.json"
#> 
#> [[6]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/.specmill/helpers/api_request.json"
#> 
#> [[6]]$action
#> [1] "write"
#> 
#> 
#> [[7]]
#> [[7]]$file
#> [1] ".Rbuildignore"
#> 
#> [[7]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/.Rbuildignore"
#> 
#> [[7]]$action
#> [1] "write"
#> 
#> 
#> [[8]]
#> [[8]]$file
#> [1] "DESCRIPTION"
#> 
#> [[8]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/DESCRIPTION"
#> 
#> [[8]]$action
#> [1] "write"
#> 
#> 
#> [[9]]
#> [[9]]$file
#> [1] "NAMESPACE"
#> 
#> [[9]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/NAMESPACE"
#> 
#> [[9]]$action
#> [1] "write"
#> 
#> 
#> [[10]]
#> [[10]]$file
#> [1] "LICENSE"
#> 
#> [[10]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/LICENSE"
#> 
#> [[10]]$action
#> [1] "write"
#> 
#> 
#> [[11]]
#> [[11]]$file
#> [1] ".specmill/manifest.json"
#> 
#> [[11]]$path
#> [1] "/tmp/RtmpkdC3pi/hook-client-24bc3e3e3f1a/.specmill/manifest.json"
#> 
#> [[11]]$action
#> [1] "write"
#> 
#> 
#> attr(,"configuration")
#> attr(,"configuration")$files
#> attr(,"configuration")$files$`schema/openapi.json`
#> [1] "{\n  \"openapi\": \"3.0.3\",\n  \"info\": {\"title\": \"Catalogue\", \"version\": \"1\"},\n  \"paths\": {\n    \"/items/{item_id}\": {\"get\": {\"operationId\": \"get_item\", \"parameters\": [\n      {\"name\": \"item_id\", \"in\": \"path\", \"required\": true, \"schema\": {\"type\": \"string\"}},\n      {\"name\": \"language\", \"in\": \"query\", \"schema\": {\"type\": \"string\", \"enum\": [\"en\", \"fr\"]}}\n    ], \"responses\": {\"200\": {\"description\": \"Item\"}}}},\n    \"/items\": {\n      \"get\": {\"operationId\": \"list_items\", \"parameters\": [\n        {\"name\": \"page\", \"in\": \"query\", \"schema\": {\"type\": \"integer\", \"minimum\": 1}}\n      ], \"responses\": {\"200\": {\"description\": \"Items\"}}},\n      \"post\": {\"operationId\": \"create_item\", \"requestBody\": {\"required\": true, \"content\": {\n        \"application/json\": {\"schema\": {\"$ref\": \"#/components/schemas/Item\"}}\n      }}, \"responses\": {\"201\": {\"description\": \"Created\"}}}\n    },\n    \"/refresh\": {\"post\": {\"operationId\": \"refresh\", \"responses\": {\"204\": {\"description\": \"Refreshed\"}}}}\n  },\n  \"components\": {\"schemas\": {\"Item\": {\"type\": \"object\", \"required\": [\"title\"], \"properties\": {\n    \"title\": {\"type\": \"string\"}, \"count\": {\"type\": \"integer\"}\n  }}}}\n}"
#> 
#> attr(,"configuration")$files$specmill.yml
#> [1] "config_version: 1\n# Package identity; keep consistent with DESCRIPTION.\npackage: hookclient\n# Service configuration files, relative to the package root.\nservices:\n- apis/default.yml\n# Package-wide limits: services cannot re-enable these excluded methods or paths.\nselection:\n  methods:\n  - GET\n  - POST\n  - PUT\n  - PATCH\n  - DELETE\n  - HEAD\n  - OPTIONS\n  - TRACE\n  exclude: []\n# Default runtime helper; services may override it.\nhelper: api_request\n# Generate help and exports unless a service overrides this setting.\ndocumentation: yes\n# Shared settings; service defaults and individual overrides take precedence.\n# Request controls: seconds per attempt, retries after the first attempt, and explicit write replay permission.\n# Runtime package.request options override these generated defaults.\n# Keep function names and output files in service YAML.\ndefaults:\n  implementation: generated\n  request_controls:\n    timeout: 30\n    max_retries: 0\n    retry_writes: no\n  batch:\n    max_items: ~\n    max_bytes: ~\n# authentication: {} # Opt in to generated auth when the schema declares security.\n# Optional formatting: uncomment and use your exact installed air version.\n# formatter: {name: air, version: \"0.9.0\"}\n# Callback source files to fingerprint; pass their functions via callbacks, too.\ncallback_files: []"
#> 
#> attr(,"configuration")$files$`apis/default.yml`
#> [1] "id: hookclient\n# Local schema files; patterns are file globs, exclude matches basenames by regex.\n# All paths resolve from the package root.\nschemas:\n  files:\n  - schema/openapi.json\n  patterns: []\n  exclude: []\n# An operation must pass methods AND include AND not match exclude (path regexes).\n# Keep only GET and POST below to omit PUT/PATCH/DELETE wrappers.\n# Leave their include/name entries in place; regeneration removes unchanged owned output.\n# An empty include selects nothing; remove include to allow every matching operation.\nselection:\n  methods:\n  - GET\n  - POST\n  - PUT\n  - PATCH\n  - DELETE\n  - HEAD\n  - OPTIONS\n  - TRACE\n  exclude: []\n  include:\n  - GET /items\n  - POST /items\n  - GET /items/{item_id}\n  - POST /refresh\n# Edit the public R function names here; keys stay METHOD /original/path.\nnames:\n  GET /items: list_items\n  POST /items: create_item\n  GET /items/{item_id}: get_item\n  POST /refresh: refresh\n# Shared operation settings. Per-operation settings below override these.\n# file groups wrappers in one R file; omit it for one file per function.\n# Example: add file: R/endpoints.R under defaults.\n# docs can set title, description, return, parameters, examples, tags and lifecycle.\ndefaults: {}\n# Optional overrides keyed by METHOD /original/path. Replace {} with entries.\n# Each entry can set file, helper, parameters, parameter_order and docs.\n# parameters keys use the original location and name, e.g. \"query limit\".\n# Example parameter setting: {name: max_results, default: 10, description: Maximum results.}\n# Advanced facades use inputs, extra_parameters and request.arguments mappings.\noperations: {}\n# Inherited from specmill.yml; uncomment to override for this service:\n# helper: api_request\n# documentation: true\n# Optional hooks: define client functions before enabling these settings.\n# hooks: {} # Public wrapper name -> pre_request/post_response hook-name sequences.\n# hook_callback: run_hook\n# hook_config: inst/hooks.yml # Alternative to inline hooks, not both.\n# prepare: prepare_operation # Development callback; pass an explicit callbacks environment.\n# policy_version: \"1\" # Your review label, recorded in generation metadata.\n# Optional fixed request expectations for generated tests:\n# contracts: {} # Public wrapper name -> fixed request expectations.\n# contracts_file: tests/testthat/contracts.rds\n# response_fixture: {} # Mock response used by inline single-call contracts.\n# Full configuration examples: https://seanthimons.github.io/specmill/articles/configuration.html"
#> 
#> 
#> attr(,"configuration")$operations
#> attr(,"configuration")$operations[[1]]
#> attr(,"configuration")$operations[[1]]$key
#> [1] "GET /items"
#> 
#> attr(,"configuration")$operations[[1]]$tag
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[1]]$group
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[1]]$name
#> [1] "list_items"
#> 
#> 
#> attr(,"configuration")$operations[[2]]
#> attr(,"configuration")$operations[[2]]$key
#> [1] "POST /items"
#> 
#> attr(,"configuration")$operations[[2]]$tag
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[2]]$group
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[2]]$name
#> [1] "create_item"
#> 
#> 
#> attr(,"configuration")$operations[[3]]
#> attr(,"configuration")$operations[[3]]$key
#> [1] "GET /items/{item_id}"
#> 
#> attr(,"configuration")$operations[[3]]$tag
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[3]]$group
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[3]]$name
#> [1] "get_item"
#> 
#> 
#> attr(,"configuration")$operations[[4]]
#> attr(,"configuration")$operations[[4]]$key
#> [1] "POST /refresh"
#> 
#> attr(,"configuration")$operations[[4]]$tag
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[4]]$group
#> [1] "default"
#> 
#> attr(,"configuration")$operations[[4]]$name
#> [1] "refresh"
#> 
#> 
#> 
#> attr(,"configuration")$diagnostics
#> attr(,"configuration")$diagnostics[[1]]
#> attr(,"configuration")$diagnostics[[1]]$key
#> [1] "GET /items"
#> 
#> attr(,"configuration")$diagnostics[[1]]$code
#> [1] "missing_tag"
#> 
#> attr(,"configuration")$diagnostics[[1]]$message
#> [1] "Assigned to default"
#> 
#> 
#> attr(,"configuration")$diagnostics[[2]]
#> attr(,"configuration")$diagnostics[[2]]$key
#> [1] "POST /items"
#> 
#> attr(,"configuration")$diagnostics[[2]]$code
#> [1] "missing_tag"
#> 
#> attr(,"configuration")$diagnostics[[2]]$message
#> [1] "Assigned to default"
#> 
#> 
#> attr(,"configuration")$diagnostics[[3]]
#> attr(,"configuration")$diagnostics[[3]]$key
#> [1] "GET /items/{item_id}"
#> 
#> attr(,"configuration")$diagnostics[[3]]$code
#> [1] "missing_tag"
#> 
#> attr(,"configuration")$diagnostics[[3]]$message
#> [1] "Assigned to default"
#> 
#> 
#> attr(,"configuration")$diagnostics[[4]]
#> attr(,"configuration")$diagnostics[[4]]$key
#> [1] "POST /refresh"
#> 
#> attr(,"configuration")$diagnostics[[4]]$code
#> [1] "missing_tag"
#> 
#> attr(,"configuration")$diagnostics[[4]]$message
#> [1] "Assigned to default"
proposal <- specmill::configure_hooks(root)
proposal$changes
#> [[1]]
#> [[1]]$file
#> [1] "R/hook_registry.R"
#> 
#> [[1]]$action
#> [1] "create"
#> 
#> [[1]]$before
#> NULL
#> 
#> [[1]]$after
#> [1] "# Client-owned hook executor. Edit this file; regeneration preserves it.\nread_hook_config <- function() {\n  # ponytail: read once per stage; cache with an explicit reload if profiling warrants it.\n  path <- system.file('hooks.yml', package = \"hookclient\", mustWork = TRUE)\n  config <- yaml::read_yaml(path, eval.expr = FALSE)\n  if (\n    !is.list(config) ||\n      (length(config) &&\n        (is.null(names(config)) || anyDuplicated(names(config))))\n  ) {\n    stop('hooks.yml must map public function names to hook stages')\n  }\n  config\n}\n\nrun_hook <- function(fn_name, hook_type, data) {\n  if (\n    length(hook_type) != 1L ||\n      is.na(hook_type) ||\n      !hook_type %in% c('pre_request', 'post_response')\n  ) {\n    stop('Unsupported hook stage')\n  }\n  entry <- read_hook_config()[[fn_name]]\n  if (\n    !is.null(entry) &&\n      (!is.list(entry) ||\n        is.null(names(entry)) ||\n        anyDuplicated(names(entry)) ||\n        any(!names(entry) %in% c('pre_request', 'post_response')))\n  ) {\n    stop('Invalid hook stages for ', fn_name)\n  }\n  chain <- entry[[hook_type]]\n  if (is.null(chain) || !length(chain)) {\n    return(if (hook_type == 'post_response') data$result else data)\n  }\n  if (\n    !(is.character(chain) || is.list(chain)) ||\n      !is.null(names(chain)) ||\n      !all(vapply(\n        chain,\n        function(x) {\n          is.character(x) && length(x) == 1L && !is.na(x) && nzchar(x)\n        },\n        logical(1)\n      ))\n  ) {\n    stop('Hook chain must be a sequence of function names for ', fn_name)\n  }\n  for (hook_name in chain) {\n    data <- tryCatch(\n      {\n        hook <- get(hook_name, envir = environment(run_hook), inherits = FALSE)\n        if (!is.function(hook)) {\n          stop('Configured hook is not a function')\n        }\n        hook(data)\n      },\n      error = function(parent) {\n        stop(errorCondition(\n          paste(\n            fn_name,\n            hook_type,\n            hook_name,\n            conditionMessage(parent),\n            sep = ': '\n          ),\n          class = 'client_hook_error',\n          parent = parent,\n          function_name = fn_name,\n          hook_name = hook_name,\n          stage = hook_type\n        ))\n      }\n    )\n  }\n  data\n}"
#> 
#> 
#> [[2]]
#> [[2]]$file
#> [1] "inst/hooks.yml"
#> 
#> [[2]]$action
#> [1] "create"
#> 
#> [[2]]$before
#> NULL
#> 
#> [[2]]$after
#> [1] "# Public wrapper names map to ordered pre_request/post_response chains.\n# Define hook functions in R/ before enabling them here.\n# Example:\n# get_item:\n#   pre_request: [normalize_item_id]\n#   post_response: [extract_item]\n{}"
proposal$missing_imports
#> [1] "yaml"

Planning does not write files. The proposed executor reads YAML using yaml, so add that package to the client’s DESCRIPTION under Imports. Keep all existing imports. In this disposable example we make the metadata edit programmatically:

metadata <- read.dcf(file.path(root, 'DESCRIPTION'))
metadata[1, 'Imports'] <- paste(metadata[1, 'Imports'], 'yaml', sep = ', ')
write.dcf(metadata, file.path(root, 'DESCRIPTION'))
specmill::configure_hooks(root, mode = 'apply')
#> $files
#> $files$`R/hook_registry.R`
#> [1] "# Client-owned hook executor. Edit this file; regeneration preserves it.\nread_hook_config <- function() {\n  # ponytail: read once per stage; cache with an explicit reload if profiling warrants it.\n  path <- system.file('hooks.yml', package = \"hookclient\", mustWork = TRUE)\n  config <- yaml::read_yaml(path, eval.expr = FALSE)\n  if (\n    !is.list(config) ||\n      (length(config) &&\n        (is.null(names(config)) || anyDuplicated(names(config))))\n  ) {\n    stop('hooks.yml must map public function names to hook stages')\n  }\n  config\n}\n\nrun_hook <- function(fn_name, hook_type, data) {\n  if (\n    length(hook_type) != 1L ||\n      is.na(hook_type) ||\n      !hook_type %in% c('pre_request', 'post_response')\n  ) {\n    stop('Unsupported hook stage')\n  }\n  entry <- read_hook_config()[[fn_name]]\n  if (\n    !is.null(entry) &&\n      (!is.list(entry) ||\n        is.null(names(entry)) ||\n        anyDuplicated(names(entry)) ||\n        any(!names(entry) %in% c('pre_request', 'post_response')))\n  ) {\n    stop('Invalid hook stages for ', fn_name)\n  }\n  chain <- entry[[hook_type]]\n  if (is.null(chain) || !length(chain)) {\n    return(if (hook_type == 'post_response') data$result else data)\n  }\n  if (\n    !(is.character(chain) || is.list(chain)) ||\n      !is.null(names(chain)) ||\n      !all(vapply(\n        chain,\n        function(x) {\n          is.character(x) && length(x) == 1L && !is.na(x) && nzchar(x)\n        },\n        logical(1)\n      ))\n  ) {\n    stop('Hook chain must be a sequence of function names for ', fn_name)\n  }\n  for (hook_name in chain) {\n    data <- tryCatch(\n      {\n        hook <- get(hook_name, envir = environment(run_hook), inherits = FALSE)\n        if (!is.function(hook)) {\n          stop('Configured hook is not a function')\n        }\n        hook(data)\n      },\n      error = function(parent) {\n        stop(errorCondition(\n          paste(\n            fn_name,\n            hook_type,\n            hook_name,\n            conditionMessage(parent),\n            sep = ': '\n          ),\n          class = 'client_hook_error',\n          parent = parent,\n          function_name = fn_name,\n          hook_name = hook_name,\n          stage = hook_type\n        ))\n      }\n    )\n  }\n  data\n}"
#> 
#> $files$`inst/hooks.yml`
#> [1] "# Public wrapper names map to ordered pre_request/post_response chains.\n# Define hook functions in R/ before enabling them here.\n# Example:\n# get_item:\n#   pre_request: [normalize_item_id]\n#   post_response: [extract_item]\n{}"
#> 
#> 
#> $changes
#> $changes[[1]]
#> $changes[[1]]$file
#> [1] "R/hook_registry.R"
#> 
#> $changes[[1]]$action
#> [1] "create"
#> 
#> $changes[[1]]$before
#> NULL
#> 
#> $changes[[1]]$after
#> [1] "# Client-owned hook executor. Edit this file; regeneration preserves it.\nread_hook_config <- function() {\n  # ponytail: read once per stage; cache with an explicit reload if profiling warrants it.\n  path <- system.file('hooks.yml', package = \"hookclient\", mustWork = TRUE)\n  config <- yaml::read_yaml(path, eval.expr = FALSE)\n  if (\n    !is.list(config) ||\n      (length(config) &&\n        (is.null(names(config)) || anyDuplicated(names(config))))\n  ) {\n    stop('hooks.yml must map public function names to hook stages')\n  }\n  config\n}\n\nrun_hook <- function(fn_name, hook_type, data) {\n  if (\n    length(hook_type) != 1L ||\n      is.na(hook_type) ||\n      !hook_type %in% c('pre_request', 'post_response')\n  ) {\n    stop('Unsupported hook stage')\n  }\n  entry <- read_hook_config()[[fn_name]]\n  if (\n    !is.null(entry) &&\n      (!is.list(entry) ||\n        is.null(names(entry)) ||\n        anyDuplicated(names(entry)) ||\n        any(!names(entry) %in% c('pre_request', 'post_response')))\n  ) {\n    stop('Invalid hook stages for ', fn_name)\n  }\n  chain <- entry[[hook_type]]\n  if (is.null(chain) || !length(chain)) {\n    return(if (hook_type == 'post_response') data$result else data)\n  }\n  if (\n    !(is.character(chain) || is.list(chain)) ||\n      !is.null(names(chain)) ||\n      !all(vapply(\n        chain,\n        function(x) {\n          is.character(x) && length(x) == 1L && !is.na(x) && nzchar(x)\n        },\n        logical(1)\n      ))\n  ) {\n    stop('Hook chain must be a sequence of function names for ', fn_name)\n  }\n  for (hook_name in chain) {\n    data <- tryCatch(\n      {\n        hook <- get(hook_name, envir = environment(run_hook), inherits = FALSE)\n        if (!is.function(hook)) {\n          stop('Configured hook is not a function')\n        }\n        hook(data)\n      },\n      error = function(parent) {\n        stop(errorCondition(\n          paste(\n            fn_name,\n            hook_type,\n            hook_name,\n            conditionMessage(parent),\n            sep = ': '\n          ),\n          class = 'client_hook_error',\n          parent = parent,\n          function_name = fn_name,\n          hook_name = hook_name,\n          stage = hook_type\n        ))\n      }\n    )\n  }\n  data\n}"
#> 
#> 
#> $changes[[2]]
#> $changes[[2]]$file
#> [1] "inst/hooks.yml"
#> 
#> $changes[[2]]$action
#> [1] "create"
#> 
#> $changes[[2]]$before
#> NULL
#> 
#> $changes[[2]]$after
#> [1] "# Public wrapper names map to ordered pre_request/post_response chains.\n# Define hook functions in R/ before enabling them here.\n# Example:\n# get_item:\n#   pre_request: [normalize_item_id]\n#   post_response: [extract_item]\n{}"
#> 
#> 
#> 
#> $missing_imports
#> character(0)
#> 
#> $collisions
#> named list()

The setup creates two files:

File Responsibility
R/hook_registry.R Client-owned read_hook_config() and run_hook() executor
inst/hooks.yml Ordered chains keyed by public wrapper name

No endpoint is enabled automatically. Setup refuses to overwrite customized files or add a second executor when it finds those function names elsewhere. If your client already has a hook executor, keep it and configure its callback and YAML path instead. You do not need this scaffold.

The generated executor adds a yaml runtime dependency. It adds no dependency on specmill or a separate hook framework. Commit the executor and edit it as ordinary client code. It reads the installed YAML on each stage, without a cache. If this becomes measurable overhead, add a cache and an explicit reload function in the client.

Enable pre-request and post-response hooks

In apis/default.yml, add these fields at service level:

hook_callback: run_hook
hook_config: inst/hooks.yml

For a multi-API configuration, place them inside each participating entry under groups, alongside that group’s names and operations. They are not API container or project-level fields. Each group can reference the same registry. Hook keys are the final public R names, including any API prefix or rename.

service_path <- file.path(root, 'apis/default.yml')
service <- yaml::read_yaml(service_path, handlers = list(seq = function(x) x))
service$hook_callback <- 'run_hook'
service$hook_config <- 'inst/hooks.yml'
yaml::write_yaml(service, service_path)

Write the declarations in inst/hooks.yml:

writeLines(c(
  'get_item:',
  '  pre_request: [normalize_item_id]',
  '  post_response: [extract_item]'
), file.path(root, 'inst/hooks.yml'))

Define those functions in a separate client-owned file. This example trims the identifier, rejects an empty identifier, and unwraps a response’s data field. The post-hook preserves prepared requests returned by the client’s dry-run mode.

writeLines(c(
  'normalize_item_id <- function(state) {',
  '  id <- trimws(state$params$item_id)',
  '  if (length(id) != 1L || is.na(id) || !nzchar(id)) stop("Supply an item ID")',
  '  state$params$item_id <- id',
  '  state',
  '}',
  '',
  'extract_item <- function(state) {',
  '  if (inherits(state$result, "httr2_request")) return(state$result)',
  '  state$result$data',
  '}'
), file.path(root, 'R/hooks_items.R'))

Define functions before generating wrappers. Generation validates referenced hook names and stages. Keep these functions out of generated endpoint files.

You can instead use inline hooks in the service configuration:

hook_callback: run_hook
hooks:
  get_item:
    pre_request: [normalize_item_id]
    post_response: [extract_item]

Choose hooks or hook_config, never both. Inline configuration tells the generator which stages to emit; it does not supply the runtime executor’s registry. The scaffold reads inst/hooks.yml, so using that same file through hook_config avoids maintaining two declarations. A custom executor may store its policy in R instead.

Understand the call contract

The generated flow is:

public input validation
  -> run_hook(name, "pre_request", list(params = params))
  -> update public parameters from returned state$params
  -> request helper
  -> run_hook(name, "post_response", list(result = result, params = params))
  -> final return value

Hooks run once around the helper call. They do not run once per internal retry, page, or batch. Transport errors propagate; post_response is not an error or finally handler and does not receive failed HTTP responses automatically.

Each hook receives one argument. Pre-hooks return a list containing params. Only returned parameter names matching public inputs replace those inputs. To remove a value, retain its name with a NULL value, for example state$params['language'] <- list(NULL). Dropping a name leaves the original public value unchanged. New internal request values require explicit mappings.

Chains run in declaration order, with each return value passed to the next hook. In a post-chain, keep returning state until the final hook returns the public result, or deliberately make later hooks accept the earlier hook’s result type. The scaffold passes each hook’s return value unchanged to the next hook. If your hooks need the wrapper name or stage in their state, add those fields in your client-owned executor.

Schema-derived public inputs are validated before the pre-hook. A hook cannot repair an input already rejected by that validation. Use explicit inputs and request mappings when you need a different public contract; see operation settings. After transformation, hooks own validation of their constructed values; there is no second schema validation gate. Request-helper transport checks still apply.

Generate and test offline

Preview, inspect the generated source, then apply:

# Offline fixture only; real schemas use the required validation gate.
plan <- specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'plan')
plan
#> Schema validation
#>   openapi.json: skipped
#> Selected operations (4)
#>   hookclient: GET /items/{item_id} -> get_item
#>   hookclient: GET /items -> list_items
#>   hookclient: POST /items -> create_item
#>   hookclient: POST /refresh -> refresh
#> 
#> Excluded operations (0)
#> 
#> Files to remove
#> Server selection requires an explicit override at runtime:
#>   GET /items/{item_id}: Relative server URL requires a recorded origin or explicit base URL override
#>   GET /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /refresh: Relative server URL requires a recorded origin or explicit base URL override
specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'apply')
#> Schema validation
#>   openapi.json: skipped
#> Selected operations (4)
#>   hookclient: GET /items/{item_id} -> get_item
#>   hookclient: GET /items -> list_items
#>   hookclient: POST /items -> create_item
#>   hookclient: POST /refresh -> refresh
#> 
#> Excluded operations (0)
#> 
#> Files to remove
#> Server selection requires an explicit override at runtime:
#>   GET /items/{item_id}: Relative server URL requires a recorded origin or explicit base URL override
#>   GET /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /refresh: Relative server URL requires a recorded origin or explicit base URL override
wrapper <- readLines(file.path(root, 'R/get_item.R'))
cat(grep('run_hook|skip_request|changed|result <- api_request', wrapper, value = TRUE), sep = '\n')
#>   state <- run_hook("get_item", "pre_request", base::list(params = params))
#>   if (base::isTRUE(state$skip_request)) base::return(state$result)
#>   changed <- base::intersect(base::names(params), base::names(state$params))
#>   params[changed] <- state$params[changed]
#>   result <- api_request(method = "GET", path = "/items/{item_id}", path_params = base::list("item_id" = params[["item_id"]]), query = base::list("language" = params[["language"]]), body = NULL, request_controls = list(timeout = 30L, max_retries = 0L, retry_writes = FALSE), server = list(diagnostic = "Relative server URL requires a recorded origin or explicit base URL override"))
#>   result <- run_hook("get_item", "post_response", base::list(result = result, params = params))

This check loads client functions into an isolated environment and replaces the HTTP helper with a recording function. It also points the config reader at the source YAML. That replacement is for this source-level test only; the installed executor uses system.file() to find its own package’s registry.

client <- new.env(parent = baseenv())
for (path in list.files(file.path(root, 'R'), '\\.R$', full.names = TRUE)) {
  sys.source(path, envir = client)
}
client$read_hook_config <- function() yaml::read_yaml(file.path(root, 'inst/hooks.yml'))
calls <- list()
client$api_request <- function(...) {
  calls[[length(calls) + 1L]] <<- list(...)
  list(data = list(id = 'abc', title = 'Example'))
}
stopifnot(identical(client$get_item(' abc ')$id, 'abc'))
stopifnot(identical(calls[[1]]$path_params$item_id, 'abc'))
error <- tryCatch(client$get_item('   '), error = identity)
stopifnot(inherits(error, 'client_hook_error'), length(calls) == 1L)
stopifnot(identical(error$hook_name, 'normalize_item_id'))
specmill::check_client_hooks(
  root, yaml::read_yaml(file.path(root, 'inst/hooks.yml')),
  hooks = client
)
specmill::generate_client(validation = FALSE, root, config = 'specmill.yml', mode = 'check')
#> Schema validation
#>   openapi.json: skipped
#> Selected operations (4)
#>   hookclient: GET /items/{item_id} -> get_item
#>   hookclient: GET /items -> list_items
#>   hookclient: POST /items -> create_item
#>   hookclient: POST /refresh -> refresh
#> 
#> Excluded operations (0)
#> 
#> Files to remove
#> Server selection requires an explicit override at runtime:
#>   GET /items/{item_id}: Relative server URL requires a recorded origin or explicit base URL override
#>   GET /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /items: Relative server URL requires a recorded origin or explicit base URL override
#>   POST /refresh: Relative server URL requires a recorded origin or explicit base URL override

Keep an equivalent check in your client’s tests. Test chain order, representative results, empty/invalid inputs, and any skip behavior you implement. Generated request contract tests cover helper calls; they do not establish that your response transformation is correct. A failed hook stops the chain and carries its original condition in error$parent, plus the function, stage, and hook name.

Before distributing the package, install it and exercise the executor from the installed namespace. This catches missing inst/hooks.yml, missing runtime imports, and accidental development dependencies:

# Run in a shell, replacing the path with your client root:
# R CMD INSTALL path/to/client
# R CMD build path/to/client
# R CMD check --no-manual hookclient_0.0.0.9000.tar.gz

library(hookclient)
stopifnot(file.exists(system.file('hooks.yml', package = 'hookclient')))
hookclient::hookclient_dry_run(TRUE)
request <- hookclient::get_item(' abc ')
stopifnot(inherits(request, 'httr2_request'))
hookclient::hookclient_dry_run(FALSE)

Pass extra state or skip a request

A pre-hook can return state$skip_request <- TRUE and a state$result value. By default the wrapper returns that result immediately, without a helper or post-hook call. To format cached or locally computed results through the post-chain, configure the operation:

operations:
  GET /items/{item_id}:
    post_on_skip: true
    post_state: hook_state

post_on_skip and post_state are independent options. Both require a configured pre-hook. post_state: hook_state passes the entire pre-state to the post-hook, with result replaced by the helper result or skipped result. It is useful for metadata such as the original identifier or a local lookup outcome.

For example, replace the pre-hook above with this version and regenerate after adding the operation settings:

normalize_item_id <- function(state) {
  state$original_id <- state$params$item_id
  state$params$item_id <- trimws(state$params$item_id)
  id <- state$params$item_id
  if (length(id) != 1L || is.na(id) || !nzchar(id)) stop('Supply an item ID')
  if (identical(state$params$item_id, 'local')) {
    state$skip_request <- TRUE
    state$result <- list(data = list(id = 'local', title = 'Local item'))
  }
  state
}

The skipped result deliberately has the same shape as the helper result, so extract_item() handles both. The wrapper does not stop a pre-chain early when a hook sets skip_request; later pre-hooks still run.

To send data constructed outside params, provide explicit helper mappings:

operations:
  POST /items:
    request:
      arguments:
        method: {value: POST}
        path: {value: /items}
        path_params: {value: {}}
        query: {value: {}}
        body: {from: [hook_state, request, body]}

This requires a pre-hook on create_item that sets state$request$body. request.arguments specifies the helper call, so include all arguments your helper requires. For additional public options use extra_parameters in the operation configuration. See request bindings for authentication, headers, body, and options mappings. Hook configuration alone does not change a signature.

Regeneration and ownership

Commit the schema, project/service YAML, runtime YAML, hook functions, executor, generated wrappers, documentation, tests, and .specmill/manifest.json together. Use configure_hooks() for initial setup only. It refuses to overwrite edits. Use generate_client() to reconcile wrappers after configuration changes.

Change Required development step
Edit a hook function body Run its tests; reinstall or reload client code
Reorder or replace names in an already emitted stage Validate and regenerate/check; reinstall to ship the updated YAML
Add the first hook or remove the last hook for a stage Regenerate; the wrapper must gain or lose that stage call
Change parameters, request mappings, skip behavior, or post-state settings Regenerate and test the resulting helper calls and hook state
Rename a public wrapper Update the hook YAML key and service names together, then regenerate
Update a schema Review schema and configuration differences, regenerate, then run checks
Update the executor template in specmill Review and manually adopt changes in the client-owned executor

The executor reads the installed registry. Editing inst/hooks.yml in a source checkout does not change an already installed package. Reinstall and start a fresh R session when verifying shipped behavior. Do not edit the installed library as your development workflow.

specmill::generate_client(root, config = 'specmill.yml', mode = 'plan')
# Review the proposed changes and diagnostics.
specmill::generate_client(root, config = 'specmill.yml', mode = 'apply')
specmill::generate_client(root, config = 'specmill.yml', mode = 'check')

check makes no changes and fails for stale or protected generated output. Normal generation retains the client-owned executor, hook functions, and runtime YAML. If a generated file was edited by hand, move that customization into hooks or operation configuration and follow the ownership workflow. Do not bypass protection by deleting the manifest.

Declarations for missing wrappers in an external hook_config file appear in unused_hooks. Review them after renaming or removing operations; they can be intentional when several services share one registry. Referenced hook functions must exist in client source. Generation validates wiring, not the behavior of every possible return value.

When adopting an existing client, preserve its hook executor, merge rules, error classes, and state conventions. Set hook_callback to that executor and hook_config to its registry. specmill reads pre/post declarations from that file. Additional public parameters and request mappings belong in operation configuration. See adopt an existing client.