Downcity
ServicesExamples

NotesService Scenario

Use a small but realistic NotesService scenario to show why some capabilities belong in a service instead of a tool

NotesService Scenario

Scenario

You are building a local engineering assistant and want it to accumulate temporary notes during one running process:

  • constraints the user already confirmed
  • repository facts that were already validated
  • notes shared across multiple sessions

At that point, this is no longer a one-shot tool. It is a long-lived state object.

Why this is a better fit for a service

Because you want:

  • long-lived in-memory state
  • reuse across sessions
  • a stable group of actions

If you force this into a pure tool, you usually end up adding:

  • a global map
  • state synchronization logic
  • lifecycle handling

which means it is already drifting toward a service.

A more realistic implementation

import {
  Agent,
  BaseService,
  type ServiceActions,
} from "@downcity/agent";

class NotesService extends BaseService {
  readonly name = "notes";

  private readonly notes: Array<{
    id: string;
    text: string;
    createdAt: number;
  }> = [];

  addNote(text: string) {
    const clean = String(text || "").trim();
    if (!clean) {
      throw new Error("note text is required");
    }
    const item = {
      id: `note-${this.notes.length + 1}`,
      text: clean,
      createdAt: Date.now(),
    };
    this.notes.push(item);
    return item;
  }

  listNotes() {
    return [...this.notes];
  }

  clearNotes() {
    this.notes.length = 0;
  }

  readonly actions: ServiceActions = {
    add: {
      execute: async ({ payload }) => {
        const body = payload as { text?: unknown };
        const item = this.addNote(String(body.text || ""));
        return {
          success: true,
          data: item as never,
        };
      },
    },
    list: {
      execute: async () => {
        return {
          success: true,
          data: {
            items: this.listNotes(),
          },
        };
      },
    },
    clear: {
      execute: async () => {
        this.clearNotes();
        return {
          success: true,
          data: {
            cleared: true,
          },
        };
      },
    },
  };
}

const notesService = new NotesService();

const agent = new Agent({
  id: "repo-helper",
  path: "/path/to/project",
  tools: {},
  services: [notesService],
});

The most important details here

1. App code can keep a direct service reference

For example:

notesService.addNote("Repository uses pnpm workspaces");
console.log(notesService.listNotes());

That is practical in the current SDK because there is no generic local SDK public convenience API centered around arbitrary service action invocation.

2. actions are still worth defining

Even if your app code often calls instance methods directly, actions still help because:

  • the shape stays aligned with the full runtime model
  • future expansion is easier
  • the service surface stays explicit

3. This is a textbook shared-state example

If one agent creates multiple sessions, they can still share the same NotesService instance.

When this would no longer be enough

If your notes need to:

  • survive restart
  • be shared across processes

then in-memory state alone is not enough and you need persistence.

But that does not change the fact that the capability still looks like a service first.