| name | cmdx |
| description | Build, debug, and document CMDx tasks and workflows in Ruby. Use when creating service/command objects with CMDx, composing tasks into workflows, handling halts and faults, or wiring inputs, outputs, callbacks, middleware, retries, and configuration. Don't use for generic Ruby refactors, Rails controller work, or non-CMDx service objects. |
CMDx Agent Skill
CMDx is a Ruby framework for composable command/service objects with declarative inputs, outputs, coercion, validation, retries, rollback, and structured observability. Deep dives live under docs/ and references/; the LLM index bundles the full doc tree for one-shot loading.
Lifecycle
Every task runs through CMDx::Runtime in this order:
- Middlewares wrap everything (
call(task) { yield } chain).
- Deprecation check (may block or log).
before_execution callbacks.
before_validation callbacks.
- Input resolution โ fetch, coerce, transform, validate.
work runs inside catch(CMDx::Signal::TAG), wrapped in retry_on.
- Output verification โ coerce/validate declared outputs.
rollback runs if the signal is failed and the task defines #rollback.
- State callbacks โ
on_complete or on_interrupted.
- Status callbacks โ
on_success / on_skipped / on_failed.
- Outcome callbacks โ
on_ok (success or skipped) or on_ko (skipped or failed).
- Result finalize + teardown โ task, errors, and root context are frozen; chain is cleared.
success! / skip! / fail! / throw! are control-flow tokens that throw a CMDx::Signal caught by Runtime โ anything after a halt is unreachable. They only work inside the signal catch (input resolution, work, output verification); calling them from before_execution / before_validation callbacks or middleware bubbles past Runtime and never produces a Result. See docs/basics/setup.md for the full state diagram.
DSL Surface
| Category | Keywords |
|---|
| Inputs | required, optional, input, inputs |
| Outputs | output, outputs |
| Callbacks | before_execution, before_validation, on_success, on_skipped, on_failed, on_complete, on_interrupted, on_ok, on_ko |
| Class config | settings, retry_on, deprecation, register, deregister |
Halts (inside work) | success!, skip!, fail!, throw! |
| Workflow | include CMDx::Workflow, task, tasks |
| Execution | Task.execute, Task.execute! (aliased call / call!) |
Minimal Task
class Greet < CMDx::Task
required :name, presence: true
def work
context.greeting = "Hello, #{name}!"
end
end
result = Greet.execute(name: "World")
result.success?
result.context.greeting
Greet.execute(name: "World") { |r| r.context.greeting }
Realistic Task
class ProcessPayment < CMDx::Task
settings(tags: ["billing"])
retry_on Gateway::TimeoutError, limit: 3, jitter: :exponential
before_execution :find_order
on_success :send_receipt
required :order_id, coerce: :integer
required :amount, coerce: :big_decimal, numeric: { gt: 0 }
optional :currency, coerce: :string, default: "USD", inclusion: { in: %w[USD EUR GBP] }
output :charge_id
output :receipt_url
def work
charge = Gateway.charge!(amount: amount, currency: currency)
context.charge_id = charge.id
context.receipt_url = charge.receipt_url
end
def rollback
Gateway.refund!(context.charge_id) if context.charge_id
end
private
def find_order
@order = Order.find(order_id)
fail!("Order already paid") if @order.paid?
end
def send_receipt
ReceiptMailer.send(@order, context.receipt_url).deliver_later
end
end
Inputs
Declare with input / inputs / required / optional. Each generated reader returns the coerced, transformed, validated value โ read it instead of context.<name>, which still holds the raw input.
Pipeline per input: Source โ Default โ Coerce โ Transform โ Validate.
class Example < CMDx::Task
required :email, coerce: :string, format: { with: URI::MailTo::EMAIL_REGEXP }
optional :role, coerce: :string, default: "member", inclusion: { in: %w[admin member guest] }
input :notes
def work
email
role
notes
end
end
Coercions accept a Symbol, an Array (tried in order, first success wins), a Hash (per-coercion options), or any inline #call(value, task)-able. Built-ins: :array, :big_decimal, :boolean, :complex, :date, :date_time, :float, :hash, :integer, :rational, :string, :symbol, :time.
required :count, coerce: :integer
required :value, coerce: %i[rational big_decimal]
required :recorded_at, coerce: { date: { strptime: "%m-%d-%Y" } }
Validators โ shorthand keys: presence:, absence:, format:, length:, numeric:, inclusion:, exclusion:, plus inline validate:. Numeric/length option keys: :min/:gte, :max/:lte, :gt, :lt, :within/:in, :not_within/:not_in, :is/:eq, :is_not/:not_eq.
required :age, coerce: :integer, numeric: { gt: 0, lt: 150 }
required :code, coerce: :string, length: { is: 6 }, format: { with: /\A[A-Z0-9]+\z/ }
optional :tags, coerce: :array, length: { max: 10 }
Naming โ as:, prefix:, suffix: rename the generated reader.
input :template, prefix: true
input :format, prefix: "report_"
input :branch, suffix: true
input :scheduled_at, as: :scheduled
Sources & transforms:
input :rate, source: :current_rate
input :token, source: TokenGenerator
input :email, transform: :downcase
input :score, coerce: :integer, transform: proc { |v| v.clamp(0, 100) }
Full reference: references/inputs.md.
Outputs
Declare keys the task must write to context. Verification runs after work succeeds (skipped when work halted).
class CreateUser < CMDx::Task
required :email, coerce: :string
output :user
output :token
def work
context.user = User.create!(email: email)
context.token = JwtService.encode(user_id: context.user.id)
end
end
Every declared output is implicitly required. Outputs support default:, if:/unless:, and description: only. A missing output fails the task with result.errors[name]. For coercion, transformation, or validation use inputs (or write derived values directly inside work).
Full reference: references/outputs.md.
Workflows
Compose tasks by including CMDx::Workflow in a Task subclass. Defining #work on a workflow raises CMDx::ImplementationError.
class OnboardCustomer < CMDx::Task
include CMDx::Workflow
required :email, coerce: :string
task ValidateIdentity
task CreateAccount
task SetupBilling, if: :billing_required?
tasks SendWelcomeEmail, SendWelcomeSms, strategy: :parallel
private
def billing_required?
context.plan != "free"
end
end
- All tasks share one
Context.
- The workflow halts on the first
failed? result. Skipped tasks never halt.
- Group options:
strategy: (:sequential default, or :parallel), pool_size:, if: / unless:.
- Parallel groups deep-dup context per task and merge back on success only; the failed leaf propagates via
throw!.
Full reference: references/workflows.md.
Halting
All halt methods throw out of work โ they never return.
def work
success!("Imported #{n} rows", rows: n)
skip!("Already processed")
fail!("Not found", code: "NOT_FOUND")
throw!(InnerTask.execute(context))
end
Signatures: success! / skip! / fail! take (reason = nil, **metadata). throw! takes (other_result, **metadata) and is a no-op unless other_result.failed?.
Accumulating errors via task.errors.add(:attr, "msg") triggers an automatic fail after work โ no explicit fail! needed. The fail reason is errors.to_s (full messages joined with ". ").
Full reference: references/interruptions.md.
Faults
execute always returns a Result. execute! raises CMDx::Fault on failed? results (skip does not raise) and re-raises the original StandardError when result.cause was a non-Fault exception.
begin
MyTask.execute!(data: input)
rescue CMDx::Fault.for?(PaymentTask, BillingTask) => e
e.result
e.task
e.context
e.chain
e.message
rescue CMDx::Fault.matches? { |f| f.result.metadata[:critical] } => e
escalate(e)
end
Result
result = MyTask.execute(input: data)
result.state
result.complete?
result.interrupted?
result.status
result.success?
result.ok?
result.ko?
result.context
result.errors
result.reason
result.metadata
result.cause
result.origin
result.retries
result.duration
result
.on(:success) { |r| redirect_to(dashboard_path) }
.on(:failed) { |r| render_error(r.reason) }
.on(:skipped) { |r| log_skip(r.reason) }
case result
in ["Task", _, "complete", "success", *] then handle_success
in ["Task", _, "interrupted", "failed", reason, *] then handle_failure(reason)
end
case result
in { status: "failed", metadata: { retryable: true } } then schedule_retry
in { state: "complete", status: "success" } then celebrate
end
Full reference: references/result.md.
Callbacks
Declare via the per-event DSL helpers (before_execution, before_validation, on_success, on_skipped, on_failed, on_complete, on_interrupted, on_ok, on_ko). Each accepts a method name (Symbol), a Proc/lambda (instance_exec'd on the task with task passed as the block arg, so ->(task) { ... } is the canonical shape), or a #call(task)-able. All forms support if: / unless: gates. The underlying form is register :callback, event, callable, **opts.
class Example < CMDx::Task
before_execution :init_tracking
before_validation :normalize_input
on_success :notify, if: -> { context.notify? }
on_failed LogFailureCallback
on_complete ->(task) { Audit.log(task.class.name) }
end
The Result isn't built yet when callbacks run โ read task.context / task.errors inside, or subscribe to the :task_executed telemetry event for finalized result data.
Middleware
Wraps the entire lifecycle. Interface: call(task) { yield } (or &next_link for Procs). Must yield or CMDx::MiddlewareError is raised.
class AuditMiddleware
def call(task)
AuditLog.start(task.class.name)
yield
ensure
AuditLog.finish(task.class.name)
end
end
class MyTask < CMDx::Task
register :middleware, AuditMiddleware.new
register :middleware, ->(task, &next_link) {
task.metadata[:tracked] = true
Timer.track(task.class) { next_link.call }
}
register :middleware, OuterMiddleware, at: 0
end
No middleware ships with the gem โ pass instances (or classes responding to #call(task)) you author yourself. See docs/middlewares.md.
Retries
class Fetch < CMDx::Task
retry_on Net::OpenTimeout, Net::ReadTimeout,
limit: 3, delay: 0.5, max_delay: 5.0, jitter: :exponential
retry_on Api::Throttled, limit: 5 do |attempt, delay, _prev_delay|
delay * (attempt + 1)
end
end
- Only retries when the exception matches
retry_on. Anything else (or a matching exception after the limit) becomes a failed result with result.cause set.
- Jitter strategies:
:exponential, :half_random, :full_random, :bounded_random, a Symbol (task method), a Proc (instance_exec(attempt, delay, prev_delay)), or any #call(attempt, delay, prev_delay)-able.
- Only
work is retried โ inputs, outputs, and callbacks run once. task.errors accumulates across attempts; clear it at the start of work if you re-populate per attempt.
Inspect with result.retries / result.retried?. Docs: docs/retries.md.
Rollback
Define #rollback to undo side effects. Runtime calls it after work when the signal is failed (before completion callbacks) and flags result.rolled_back?.
class ChargeCard < CMDx::Task
def work
context.charge = Gateway.charge!(context.amount)
end
def rollback
Gateway.refund!(context.charge.id) if context.charge
end
end
Rollback is per-task. To compensate across a workflow's earlier successful tasks, use an on_failed callback on the workflow class.
Context
context[:key]
context.key
context.key = value
context.store(:key, value)
context.merge(hash)
context.fetch(:key, default)
context.retrieve(:key) { v }
context.key?(:key)
context.dig(:a, :b, :c)
context.delete(:key)
Context is frozen on root teardown โ post-execution mutations raise FrozenError. Nested tasks share the same context object unless isolated via context.deep_dup (parallel workflow groups do this automatically).
Configuration
CMDx.configure sets framework-wide defaults; settings(...) overrides per-class logger / formatter / level / backtrace cleaner / tags; everything else uses dedicated DSL.
CMDx.configure do |config|
config.default_locale = "en"
config.logger = Logger.new($stdout)
config.log_formatter = CMDx::LogFormatters::JSON.new
config.middlewares.register MyGlobalMiddleware
config.callbacks.register :on_failed, ErrorTracker
config.coercions.register :money, MoneyCoercion
config.validators.register :api_key, ApiKeyValidator
config.telemetry.subscribe(:task_executed) { |event| ... }
end
class MyTask < CMDx::Task
settings(tags: ["critical"], log_level: Logger::DEBUG)
retry_on Net::OpenTimeout, limit: 3
deprecation :warn, if: -> { Rails.env.production? }
register :middleware, TimingMiddleware.new
register :validator, :api_key, ApiKeyValidator
end
Full reference (registry matrix, telemetry events, Rails wiring): references/configuration.md.
Exceptions
Flat hierarchy rooted at CMDx::Error (aliased CMDx::Exception):
StandardError
โโโ CMDx::Error
โโโ CMDx::DefinitionError # input name collides with existing method
โโโ CMDx::DeprecationError # deprecation :error was triggered
โโโ CMDx::ImplementationError # missing #work, or #work defined on a Workflow
โโโ CMDx::MiddlewareError # middleware didn't yield
โโโ CMDx::Fault # raised by execute! on failed? results
CMDx::Error subclasses other than Fault propagate through execute unconverted โ they indicate framework misuse, not runtime failure.
Common Pitfalls
1. Forgetting def work
Raises CMDx::ImplementationError at execution time from both execute and execute!.
2. Reading context.foo instead of the generated reader
def work
context.email
end
required :email, coerce: :string
def work
email
end
Coerced input values live on the task instance, not on context. To persist them, write back explicitly (context.email = email).
3. Input declaration order
Inputs are resolved in declaration order. If one input references another via source: or an if: predicate, declare the referenced input first.
4. Middleware that forgets to yield
Silently swallowing yield raises CMDx::MiddlewareError outside the signal catch โ the failure is not convertible to a result. Always yield (or next_link.call) on every code path, including rescue / ensure.
5. Mutating the root context after teardown
Runtime freezes the root context, task, errors, and chain during teardown. Post-execution writes raise FrozenError. Use context.deep_dup before teardown when you need a mutable snapshot.
6. Assuming workflows halt on skip
Workflows halt only on failed?. Skipped tasks are no-ops; the pipeline continues. To make a step hard-fail, call fail! (not skip!).
7. execute! doesn't always raise Fault
When result.cause is a non-Fault StandardError (e.g. an ActiveRecord::RecordNotFound that slipped through work), execute! re-raises the original exception, not a Fault. Match both in production rescue blocks if needed.
8. Retries share the same task instance
context, instance variables, and task.errors persist across retry attempts. If you re-add errors each time, clear them at the start of work, or the post-work check will still fail the task.
9. task.result isn't available inside callbacks
The Result is built after callbacks run. Inside callbacks, read task.context / task.errors; for finalized result data, subscribe to the :task_executed telemetry event.
10. Calling halt methods outside work
The signal catch only wraps input resolution, work, and output verification. success! / skip! / fail! / throw! invoked from before_execution / before_validation callbacks or middleware bubble past Runtime and never produce a Result. Use errors.add from validation callbacks instead, or move the logic into work.
References
- Inputs โ declarations, coercions, validators, naming, transforms, sources, nesting
- Outputs โ declarations, verification, defaults, transforms
- Workflows โ composition, strategies, halt behavior, rollback, nesting
- Interruptions โ signals, faults, errors, propagation strategies
- Result โ states, statuses, handlers, pattern matching, chain analysis
- Configuration โ global config, settings, retries, deprecation, registries, telemetry, Rails
- Testing โ RSpec patterns using the real public API