Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Quartz.Dashboard.Agent puts a scheduler on a dashboard running somewhere else, over one outbound connection the scheduler opens itself. The dashboard shows and drives it — every page, every action, the history and the live events — with no inbound port on the worker, no HTTP API in it, and no shared database.

dotnet add package Quartz.Dashboard.Agent

Choose it over an HTTP target when the worker cannot be dialled: it is behind NAT or a firewall, it runs where no port can be opened, or the dashboard is on the other side of a trust boundary. Choose it over a store-attached target when the dashboard must not hold the database credentials, or when the worker runs on the in-memory store.

Setup

The dashboard accepts agents with AcceptAgents, which maps the agent hub at {DashboardPath}/agents:

builder.Services.AddQuartzDashboard(options => options.AcceptAgents(agents =>
{
    // The token every agent presents, as an Authorization: Bearer header. Keep it in a
    // secret store: whoever holds it can register a scheduler on this dashboard.
    agents.Tokens.Primary = builder.Configuration["Dashboard:AgentToken"];
}));

The worker dials it:

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.AddQuartz(q =>
{
    q.UseDashboardAgent(agent =>
    {
        // The dashboard's path followed by /agents: AcceptAgents maps the hub there.
        agent.Endpoint = new Uri("https://ops.example.com/quartz/agents");

        // Sent as an Authorization: Bearer header on every connection, never in the URL.
        agent.Token = builder.Configuration["Dashboard:AgentToken"];

        // The first half of the scheduler's key on the dashboard: worker-1/QuartzScheduler.
        // Defaults to the machine name.
        agent.Target = "worker-1";

        // Which job types a request through this agent may name. Unset, every one is refused.
        agent.IsJobTypeAllowed = name => name.StartsWith("Acme.Jobs.", StringComparison.Ordinal);
    });
});

builder.AddQuartzHostedService();

The dashboard then lists the scheduler as worker-1/QuartzScheduler, reached through an agent. What the dashboard side configures — tokens, rotation, liveness, the hub's authorization — is in Fronting a scheduler on another machine.

Options

UseDashboardAgent takes a DashboardAgentOptions:

OptionDefaultWhat it is
EndpointrequiredThe dashboard's agent hub: its path followed by /agents
Token—The bearer token the dashboard accepts. Required unless AccessTokenProvider is set
AccessTokenProvider—Func<CancellationToken, ValueTask<string?>>: the host's own credential, asked before every connection
Targetthe machine nameThe first half of the scheduler's key on the dashboard. No / or +
ReadOnlyfalseRefuse every operation that changes something
IsJobTypeAllowednull: every type refusedWhich job types a request may name; see What the agent accepts
IsOperationAllowednull: every operationWhich operations the agent accepts, by route name (Shutdown, PauseJob, ScheduleJob, …)
HeartbeatInterval15 sHow often the agent says it is alive; the dashboard's own interval replaces it at registration
MaxConcurrentOperations4How many of the dashboard's requests run at once; the rest queue
MaxPageSize1000The most items one listing returns; 0 for no limit
ConfigureConnection—The SignalR client's HttpConnectionOptions: a proxy, a client certificate, the transports

Validated at scheduler start: Endpoint absolute, a token or a provider, HeartbeatInterval positive, MaxConcurrentOperations at least one, MaxPageSize not negative, Target without / or +.

UseDashboardAgent also calls AddQuartzExecutionHistory(), so a worker with an agent records what it ran in memory, as a worker serving the HTTP API does. The dashboard's History page reads it through the connection.

Authenticating with the host's credential

A token is a shared secret. Where the host authenticates machines itself — a managed identity, an OIDC client credential — hand the agent a provider and hold the hub to a policy that accepts what it presents:

builder.AddQuartz(q =>
{
    q.UseDashboardAgent(agent =>
    {
        agent.Endpoint = new Uri("https://ops.example.com/quartz/agents");

        // The host's own credential instead of a shared secret: asked before every connection,
        // so an expiring token is renewed. The dashboard holds the hub to a policy that
        // accepts it: AcceptAgents(a => a.AuthorizationPolicy = "agents").
        agent.AccessTokenProvider = cancellationToken =>
            credential.GetAccessToken("api://quartz-dashboard/.default", cancellationToken);

        agent.IsJobTypeAllowed = name => name.StartsWith("Acme.Jobs.", StringComparison.Ordinal);
    });
});

Either way the credential travels as an Authorization: Bearer header on every connection, never in the URL.

The connection

q.UseDashboardAgent(agent =>
{
    agent.Endpoint = new Uri("https://ops.example.com/quartz/agents");
    agent.Token = "…";
    agent.IsJobTypeAllowed = name => name.StartsWith("Acme.Jobs.", StringComparison.Ordinal);

    // The connection is the SignalR client's: a proxy, a client certificate, the transports.
    agent.ConfigureConnection = connection =>
    {
        connection.Proxy = new WebProxy("http://proxy.internal:3128");
        connection.ClientCertificates = [X509CertificateLoader.LoadPkcs12FromFile("worker-1.pfx", password: null)];
    };
});

What the agent accepts

An agent crosses a trust boundary by design. The narrowings below are decided in the worker's process, and nothing the dashboard sends can widen them; the dashboard's own ReadOnly hides its buttons and binds no agent.

SettingRefusesUnset
IsJobTypeAllowedstoring a job by type name whose name the predicate rejects: add-job, schedule-job with a job, schedule-jobsevery job type
ReadOnlyevery operation that changes somethingnothing
IsOperationAllowedevery operation whose route name the predicate rejectsnothing
  • A refusal is 403 with problem details. The dashboard shows it as the refusal it is; the refusal of an unset IsJobTypeAllowed names the option as the remedy. The agent logs every refusal (9304).
  • With IsJobTypeAllowed unset, everything else works: triggering, pausing, rescheduling and deleting a job the scheduler already holds, and scheduling a trigger for one.
  • The predicate sees the job type name as the request wrote it, and nothing resolves it first. One type has more than one spelling, so match on the namespace: name.StartsWith("Acme.Jobs.", StringComparison.Ordinal).
  • The default is the opposite of QuartzHttpApiOptions.IsJobTypeAllowed's, because the HTTP API is on the application's own network and an agent is not. With Quartz.Jobs on the probing path, a dashboard that may name any IJob may run any executable on the worker.
builder.AddQuartz(q =>
{
    q.UseDashboardAgent(agent =>
    {
        agent.Endpoint = new Uri("https://ops.example.com/quartz/agents");
        agent.Token = builder.Configuration["Dashboard:AgentToken"];

        // Every operation that changes something is 403; the dashboard shows the refusal.
        agent.ReadOnly = true;

        // Or keep writes and refuse by route name — the operation ids the HTTP API publishes.
        agent.IsOperationAllowed = operation => operation is not ("Shutdown" or "Clear" or "DeleteJobs");

        // The job types the agent will store, matched on the name as the request wrote it.
        agent.IsJobTypeAllowed = name => name.StartsWith("Acme.Jobs.", StringComparison.Ordinal);
    });
});

How it runs

ConnectionOne outbound SignalR connection to Endpoint, over the transports the client negotiates (WebSockets first). Opened when the scheduler starts, closed when it shuts down
RegistrationThe agent registers its target, scheduler name, instance id, Quartz version and the operations it serves. A target held by a live agent of another instance is refused; the agent retries every 30 s. The same instance reconnecting takes its own entry over
HeartbeatEvery HeartbeatInterval as the dashboard set it at registration. Three missed in a row and the dashboard lists the scheduler as Unknown with when it was last heard; the next heartbeat restores it
OperationsEach page read and each action is one request down the connection, answered with the HTTP API's status and body. The dashboard cancels one that takes longer than its OperationTimeout (30 s) on both sides, queued or running. Four requests per worker may wait their turn; one more is answered 503 at once
Page sizeAn answer larger than the dashboard's MaxMessageBytes (4 MiB) is refused by the agent with 413, telling the page to lower take; the connection stays up
EventsStreamed up only while a Live Logs page watches the scheduler; what the broker drops under backpressure is dropped, as over HTTP
Dashboard downAt start, dialled again after 1 s, doubling to 30 s, until it answers; a dropped connection is redialled after 0, 2, 10 and then every 30 s, for as long as the scheduler runs
ShutdownThe agent says goodbye; the dashboard lists the scheduler as Shutdown until ForgetAfter (an hour) passes

The agent never blocks or fails the scheduler's start: a dashboard that is down is logged (9301, once per outage) and dialled again.

Logging

Under the category Quartz.Dashboard.Agent; the dashboard's side is 9111–9117 under Quartz.Dashboard.

IdLevelWhen
9300InformationRegistered with the dashboard
9301WarningThe dashboard is unreachable; retrying
9302WarningThe dashboard refused the registration
9303InformationThe dashboard closed the connection; reconnecting
9304WarningAn operation was refused: read-only, operation list, job type, page size
9305ErrorAn operation failed

Each is in Log Events.

Trust model

  • The dashboard is trusted by every agent that dials it. Whoever runs the dashboard can perform on the worker every operation the agent accepts. The agent's token or credential authenticates the dashboard to the agent as much as the reverse.
  • The agent's narrowings are the only ones that hold against a dashboard that has been taken over: ReadOnly, IsOperationAllowed, IsJobTypeAllowed.
  • Tokens are shared secrets. Keep them in a secret store, rotate them through the dashboard's two slots, and treat the dashboard host as the trust anchor of the fleet.
  • The transport is the SignalR client's. TLS, certificate validation and the proxy are what ConfigureConnection sets; the agent changes none of them.

Trimming

The package compiles with the trim, AOT and single-file analyzers on. The one warning it owns — the job type on the wire is a string the scheduler resolves — is recorded in its TrimAnalysisBaseline.cs, as Quartz.HttpClient records the same site. No trimmed canary publishes it in 4.5; see Trimming and native AOT.

Limitations

  • One dashboard instance. Agents register with the instance they dialled, and the hub keeps that registration in its process. A dashboard scaled out behind a load balancer shows each agent on one instance only.
  • The scheduler listing is the agent's own. The dashboard asks the agent about its scheduler only; another scheduler in the worker's container needs an agent of its own, with a target of its own.
  • Nothing is replayed. A page open during a disconnection sees a gap in Live Logs; the History page has what fell into it.
Help us by improving this page!
Last Updated: 10/9/26, 5:31 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Dashboard
Next
Hosted Services Integration