Plugins are how faff connects to external systems. A plugin is a Python package that implements one or both of two interfaces: PlanSource (pull vocabulary and trackers) and Audience (compile and submit timesheets).
faff-core embeds a Python interpreter and loads plugins from ~/.faff/plugins/ at runtime.
faff-core provides the base classes. Install
faff-core(the Python bindings package) as a dependency in your plugin to get access toPlanSource,Audience, and all the model types.
A PlanSource plugin fetches a plan from an external system for a given date. The plan provides vocabulary (roles, impacts, modes, subjects) and tracker data that faff uses to populate session pickers.
An Audience plugin compiles a log into a timesheet for a specific recipient and submits it. Compilation involves filtering sessions (only those relevant to this audience), optionally transforming data, and packaging the result for submission.
A plugin can implement both PlanSource and Audience — this is common when the same system provides both trackers and receives time reports.
Import from faff_core.plugins:
from faff_core.plugins import Plugin, PlanSource, Audience
from faff_core.models import Log, Plan, TimesheetAll plugins extend Plugin. The constructor is called by faff-core with the plugin's configuration:
class Plugin(ABC):
def __init__(
self,
plugin: str, # Plugin package name
name: str, # Instance name (from remote config)
config: dict, # Plugin-specific config (from remote's [connection])
defaults: dict, # Default vocabulary values
state_path: Path, # Directory for persistent state
):
...
self.id = slugify(self.name) # URL-safe identifier
self.state_path = state_path # Write tokens, caches hereclass PlanSource(Plugin):
@abstractmethod
def pull_plan(self, date: datetime.date) -> Plan:
"""
Fetch a plan for the given date.
Return a Plan with roles, impacts, modes, subjects, trackers,
and any hints derived from the remote's data.
"""class Audience(Plugin):
@abstractmethod
def compile_time_sheet(self, log: Log) -> Timesheet:
"""
Compile a timesheet from a log.
Filter and transform log sessions into a Timesheet appropriate
for this audience. Return an empty Timesheet if there's nothing
to report (e.g. no sessions with this audience's trackers).
"""
@abstractmethod
def submit_timesheet(self, timesheet: Timesheet) -> None:
"""
Submit a compiled timesheet to the external system.
Raise an exception if submission fails — faff-core will record
the error in the timesheet's .meta file.
"""Here's a minimal plugin that implements both PlanSource and Audience:
import datetime
import requests
from pathlib import Path
from faff_core.plugins import PlanSource, Audience
from faff_core.models import Log, Plan, Timesheet, Session
class MySystemPlugin(PlanSource, Audience):
def __init__(self, plugin, name, config, defaults, state_path):
super().__init__(plugin, name, config, defaults, state_path)
self.api_url = "https://api.mysystem.example.com"
self.api_key = config["api_key"]
# ── PlanSource ────────────────────────────────────────────────────────
def pull_plan(self, date: datetime.date) -> Plan:
# Fetch projects from the external system
resp = requests.get(
f"{self.api_url}/projects",
headers={"Authorization": f"Bearer {self.api_key}"},
)
resp.raise_for_status()
projects = resp.json()
# Build tracker map: {source:id → human-readable name}
trackers = {
f"{self.id}:{p['id']}": p["name"]
for p in projects
}
return Plan(
source=self.id,
valid_from=date,
valid_until=None,
roles=self.defaults.get("roles", []),
impacts=self.defaults.get("impacts", []),
modes=self.defaults.get("modes", []),
subjects=[],
trackers=trackers,
hints=[],
)
# ── Audience ──────────────────────────────────────────────────────────
def compile_time_sheet(self, log: Log) -> Timesheet:
# Include only sessions that have one of our trackers
our_prefix = f"{self.id}:"
sessions = [
s for s in log.timeline
if any(t.startswith(our_prefix) for t in s.trackers)
]
actor = {
"name": self.config.get("actor", {}).get("name", ""),
"email": self.config.get("actor", {}).get("email", ""),
}
return Timesheet(
actor=actor,
date=log.date,
compiled=datetime.datetime.now(datetime.timezone.utc),
timezone=log.timezone,
timeline=sessions,
)
def submit_timesheet(self, timesheet: Timesheet) -> None:
for session in timesheet.timeline:
duration_hours = (
(session.end - session.start).total_seconds() / 3600
)
tracker_id = next(
t.split(":", 1)[1]
for t in session.trackers
if t.startswith(f"{self.id}:")
)
resp = requests.post(
f"{self.api_url}/time-entries",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"project_id": tracker_id,
"date": str(timesheet.date),
"hours": round(duration_hours, 2),
"notes": session.note or session.title or "",
},
)
resp.raise_for_status()A plugin is a Python package. Typical layout:
~/.faff/plugins/
└── faff-plugin-my-system/
├── pyproject.toml # or setup.py
├── plugin/
│ ├── __init__.py
│ └── plugin.py # Plugin class(es)
└── README.md
The package must expose its plugin class(es) at a path faff-core can discover. By convention, the main class is importable as {package_name}.plugin.{ClassName}.
Plugins are configured in ~/.faff/config.toml (or equivalently in a ~/.faff/remotes/{id}.toml file).
In config.toml:
[[plan_remote]]
name = "my-system"
plugin = "my-system" # package name without faff-plugin- prefix
[plan_remote.config]
api_key = "sk-..."
[plan_remote.defaults]
roles = ["engineer", "manager"]
impacts = ["development", "maintenance"]
modes = ["coding", "meeting", "admin"]
[[timesheet_audience]]
name = "my-system"
plugin = "my-system"
[timesheet_audience.config.actor]
name = "Alice Smith"
email = "alice@example.com"Use self.state_path (a Path) to store anything that needs to persist between runs — OAuth tokens, cached responses, last-pull timestamps:
import json
def _save_token(self, token: str) -> None:
token_file = self.state_path / "token.json"
token_file.write_text(json.dumps({"access_token": token}))
def _load_token(self) -> str | None:
token_file = self.state_path / "token.json"
if token_file.exists():
return json.loads(token_file.read_text())["access_token"]
return Nonefaff-core creates self.state_path before calling __init__. The directory is ~/.faff/plugin_state/{plugin-id}/.
All models come from the faff-core Rust library via Python bindings. See faff-core for the full model reference.
Key things to know:
- Models are immutable. Operations return new instances rather than mutating.
log.timelineis a list ofSessionobjects.session.trackersis a list ofsource:idstrings.session.startandsession.endare timezone-awaredatetimeobjects.- Build a
Planby passing all fields as constructor arguments.
- faff-core SDK
- Remote format — vocabulary mappings complement plugin logic
- Concepts: Plugins