A service is how DeepSeek Harness plugins share capabilities: built-in services are mounted on ctx (ctx.tools, ctx.llm, ctx.agents), and any plugin can expose its own capabilities as a service for others to inject. This tutorial covers four things: how to consume services, how to provide them, how the framework protects you when a service disappears, and how to isolate service instances with isolate.

What a service is: capabilities shared between plugins

A single plugin can only do so much alone. Harness factors reusable capabilities into services — provided by one plugin, registered on the Context, and consumed by other plugins that declare a dependency on them.

The built-in services are the most familiar examples: ctx.tools is the tool runtime (ToolRuntime), ctx.llm provides LLM access, and ctx.agents manages agents. They are mounted when Harness starts, so your plugins only need to declare a dependency to use them.

Service relationship: a provider plugin extends Service and exposes itself to the Context service registry via super(ctx, name); a consumer plugin declares dependencies with inject and calls the service through ctx.<name>Provider pluginclass Metrics extends Servicesuper(ctx, 'metrics')extends Service, super(ctx, name)Consumer plugininject = ['metrics']ctx.metrics.record(…)inject declares dependenciesContext service registryctx.<name> mount pointexposesinjects
The service triangle: a provider exposes a service to the Context registry, and a consumer declares a dependency so the framework injects it.

The key point: providers and consumers never know about each other — they are coupled only by a name on the Context (such as "tools" or "metrics"). Plugins can be developed and replaced independently, which is what makes the Harness ecosystem composable.

Declare required dependencies with inject

The safest way to consume a service is to declare it as a required dependency. Export an inject array listing the service names you need:

Declare and use a required dependency
import type { Context } from '@deepseek-ai/cordis'

export const name = 'usage-reporter'
export const inject = ['tools']

export function apply(ctx: Context) {
  // when apply runs, the tools service is guaranteed to be ready
  ctx.tools.register(/* ... */)
}

The framework guarantees that when apply runs, every injected service is ready; if a service is not ready yet, the plugin waits rather than running with an unmet dependency.

Optional dependencies: query on demand with ctx.get()

Not every dependency is required. If your plugin reports an extra metric when a metrics service exists but works fine without it, leave it out of inject and query it at the use site with ctx.get():

Query an optional dependency
import type { Context } from '@deepseek-ai/cordis'

export const name = 'metrics-insights'

export function apply(ctx: Context) {
  // no inject: metrics is optional, queried only when used
  const metrics = ctx.get('metrics')
  metrics?.record('plugin_loaded', 1)
}

ctx.get() returns the instance when the service exists and undefined otherwise, which is why the call is followed by optional chaining ?.. The plugin loads normally and is never blocked by a missing enhancement.

Provide a service: extend Service

Now the reverse: expose your plugin's capabilities to others. Write a class extending Service and register it on the Context with super(ctx, name) in the constructor:

src/metrics-service.ts
import { Service, type Context } from '@deepseek-ai/cordis'

export default class MetricsService extends Service {
  static inject = ['llm']

  constructor(ctx: Context) {
    super(ctx, 'metrics')
  }

  record(event: string, value: number) {
    // implement metric recording here
  }
}

Three things matter here: super(ctx, 'metrics') decides the name consumers use to access the service; static inject declares the service's own dependencies (in this example, metrics depends on llm); and the public methods on the class (such as record) are the service's API.

Consumers simply add inject = ['metrics'] and call ctx.metrics.record(...) — indistinguishable from using a built-in service.

Typing via declaration merging

ctx.metrics is untyped by default. Extend the Context interface with TypeScript declaration merging so consumers get type hints and checking:

Augment the Context type
import type { MetricsService } from './metrics-service'

declare module '@deepseek-ai/cordis' {
  interface Context {
    metrics: MetricsService
  }
}

Put this declaration in the service module (or its type entry point), and every plugin that imports the module automatically gets the ctx.metrics type. This is the standard Cordis service pattern — it is exactly how the built-in services get their types too.

Service disappearance and automatic recovery

Required dependencies come with one more guarantee: if a service's provider unloads and the service vanishes from the Context, the framework automatically unloads every plugin that depends on it; when the service returns, it reloads them automatically.

Service disappearance and recovery flow: provider unloads, dependency detected, consumer auto-unloads, provider returns, consumer reloads1Provider unloadsService removed2Dependency checkRequired service gone3Auto unloadConsumer disposed4Provider returnsService re-registered5Auto reloadConsumer re-appliesRequired service disappears → consumers auto-unload; service returns → consumers reload
Service disappearance and recovery: dependents unload and reload automatically — no hand-written listeners required.

This means you do not listen for "service gone" events, and you do not check whether a service exists before every call — the framework guarantees your plugin either runs with the service intact or is paused entirely. It never calls into a nonexistent service.

Automatic unload still follows the standard cleanup flow: cleanup functions you registered with ctx.effect run as usual:

Cleanup on automatic unload
import type { Context } from '@deepseek-ai/cordis'

export const name = 'metrics-consumer'
export const inject = ['metrics']

export function apply(ctx: Context) {
  const timer = setInterval(() => {
    ctx.metrics.record('heartbeat', 1)
  }, 5000)

  // this cleanup also runs when the plugin is auto-unloaded due to service loss
  ctx.effect(() => {
    return () => clearInterval(timer)
  })
}

Service isolation with isolate

By default, a same-named service shares one instance globally. Sometimes you want two groups of plugins to each own a separate service instance — for example, two groups both using the Bash plugin but with different timeout values. Add isolate to the groups in cordis.yml:

cordis.yml: two isolated groups
- insert:
    - id: group-a
      isolate: { shell: true }

    - name: '@deepseek-ai/dsh-bash-local'
      group: group-a
      config:
        timeoutMs: 5000

    - id: group-b
      isolate: { shell: true }

    - name: '@deepseek-ai/dsh-bash-local'
      group: group-b
      config:
        timeoutMs: 30000

isolate: { shell: true } means plugins in that group see a private shell service instance owned by the group. The 5-second Bash in group-a and the 30-second Bash in group-b never interfere — they do not inject the same instance.

Built-in services and where to find their APIs

Harness's built-in services (tools, llm, agents, and more) evolve between releases, so memorizing a method list is the wrong approach. Each service subsystem reference page generates the service's names, methods, and source locations, and together with the TypeScript interfaces (jump to definition in your editor) they are the most reliable API documentation available.

Services and dependency injection FAQ

How do I choose between inject and ctx.get?

Ask whether the dependency is "cannot live without it" or "nice to have". Use inject for required dependencies: the framework guarantees the service is ready, and unloads and reloads your plugin automatically if the service disappears — at the cost of the plugin not loading while the service is absent. Use ctx.get() for optional dependencies: the plugin loads normally, queries the service at the use site, and falls back with optional chaining.

What happens to my plugin when a required service disappears?

The framework automatically unloads your plugin (cleanup functions registered with ctx.effect still run), then reloads it once the service returns. You write no listeners or retry logic — the plugin never runs while the service is missing.

What problem does isolate solve?

It gives each plugin group its own instance of a same-named service. The classic case is running one plugin with two coexisting configurations: two groups each run @deepseek-ai/dsh-bash-local with different timeoutMs values, and the shell instances they inject are invisible to each other.

Where do I find the full method list of a built-in service?

Each service subsystem reference page generates the service names, methods, and source locations for the current version. The most direct route is "go to definition" on ctx.<service-name> in your editor — the TypeScript interface is the authoritative API for the version you have installed.