<ContentPage eyebrow="Tutorial" title="Add tasks" description="Validate a title and save a new task between runs.">

<TutorialNavigation slug="tutorial-add-tasks" />

Start with chapter 5's program and restored sample `tasks.txt`. Reading a file is useful, but a task tool should let you add tasks without opening an editor.

## Validate and append a task

Add `InvalidTitle` to the `TaskError` declaration, then add this function after `load_tasks`:

```tea
def add_task(tasks: List[Task], title: String) -> Void ! TaskError
  if ! valid_title(title)
    throw TaskError.InvalidTitle
  end

  @append(tasks, Task(title: title, done: false))
end
```

The function accepts the existing list and a title. Calling `@append` changes that list, including the value seen by the caller. Tea's lists have shared reference semantics. A `const` binding prevents rebinding the variable; it doesn't freeze the list's contents.

We validate before appending. Empty titles, titles made only of spaces or tabs, and titles containing line breaks are rejected. Other characters, including `|` and brackets, are ordinary title text. Leading and trailing spaces are preserved.

## Encode and save the list

Add `encode_tasks` and `save_tasks` before `add_task`:

```tea
def encode_tasks(tasks: List[Task]) -> String
  var content = ""

  for task in tasks
    const marker = if(task.done) "[x]" else "[ ]"
    content = content + `${marker} ${task.title}\n`
  end

  content
end

def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
  fs.write_file_atomic(file, encode_tasks(tasks))
end
```

Encoding reverses the decoding process: each task becomes a marker, a title, and a newline. The number shown by `list` is deliberately absent from the saved record.

`fs.write_file_atomic` stages the new contents before replacing the file. That avoids leaving a partially written list if writing fails. It doesn't coordinate multiple writers; this tutorial's tool is intended for one local user at a time.

## Add the command branch

Inside `run`, before the final `usage()` call, add the `add` branch from the complete checkpoint. It checks for exactly two arguments: the command and one title. It loads the existing tasks, calls `add_task`, saves the result, and prints confirmation only after saving succeeds.

Update `help` to mention `add "title"`, and add a `TaskError.InvalidTitle` case to the outer catch block. That case prints `tasks: title must be nonempty and on one line` and exits with status `2`.

## Save your first new task

Quote a title containing spaces so your shell passes it as one argument:

```bash
tea tasks.tea add "Write a test"
```

```text
Added task 4.
```

Now run a separate process:

```bash
tea tasks.tea list
```

```text
1. [ ] Learn Tea
2. [ ] Build a CLI
4. [ ] Write a test
```

Task 3 is still complete, so the default view hides it. Open `tasks.txt` to see the new fourth line. If you started with no data file, adding a task creates it and assigns number 1.

## Complete checkpoint

Keep your existing `tasks.txt`, including the new task. This source replaces the previous `tasks.tea`:

<details>
<summary>Complete tasks.tea for this chapter</summary>

**`tasks.tea`**

```tea checkpoint="examples/tutorial/tasks/06-add-tasks/tasks.tea"
use args from "std.args"
use fs from "std.fs"
use string from "std.string"

struct Task {
  title: String
  done: Bool
}

def format_task(task: Task, number: Int) -> String
  const marker = if(task.done) "[x]" else "[ ]"
  `${number}. ${marker} ${task.title}`
end

def show_tasks(tasks: List[Task], show_all: Bool) -> Void
  if @len(tasks) == 0
    @println("No tasks yet.")
    return
  end

  var number = 1
  var shown = 0

  for task in tasks
    if show_all ||!task.done
      @println(format_task(task, number))
      shown = shown + 1
    end

    number = number + 1
  end

  if shown == 0
    @println("No open tasks.")
  end
end

error TaskError {
  InvalidData(line: Int)
  InvalidTitle
}

def valid_title(title: String) -> Bool
  const without_spaces = string.replace(title, " ", "")
  const without_tabs = string.replace(without_spaces, "\t", "")
  @len(without_tabs) > 0 &&!string.contains(title, "\n") &&!string.contains(title, "\r")
end

def decode_tasks(content: String) -> List[Task] ! TaskError
  var tasks: List[Task] = []
  var number = 1

  for line in string.lines(string.replace(content, "\r\n", "\n"))
    if @len(line) < 5 ||!(string.starts_with(line, "[ ] ") || string.starts_with(line, "[x] "))
      throw TaskError.InvalidData(number)
    end

    const title = line[4..@len(line)]

    if ! valid_title(title)
      throw TaskError.InvalidData(number)
    end

    @append(tasks, Task(title: title, done: string.starts_with(line, "[x] ")))
    number = number + 1
  end

  tasks
end

def load_tasks(file: String) -> List[Task] ! { fs.FsError, TaskError }
  if ! fs.exists(file)
    return[]
  end

  decode_tasks(fs.read_file(file))
end

def encode_tasks(tasks: List[Task]) -> String
  var content = ""

  for task in tasks
    const marker = if(task.done) "[x]" else "[ ]"
    content = content + `${marker} ${task.title}\n`
  end

  content
end

def save_tasks(file: String, tasks: List[Task]) -> Void ! fs.FsError
  fs.write_file_atomic(file, encode_tasks(tasks))
end

def add_task(tasks: List[Task], title: String) -> Void ! TaskError
  if ! valid_title(title)
    throw TaskError.InvalidTitle
  end

  @append(tasks, Task(title: title, done: false))
end

def help() -> Void
  @println("Usage: tasks [list [--all] | add \"title\" | --help]")
  @println("Tasks are numbered from 1. Listing shows open tasks; --all includes completed tasks.")
end

def usage() -> Void
  @eprintln("tasks: invalid arguments; run tasks --help for usage")
  @exit(2)
end

def run() -> Void ! { fs.FsError, TaskError }
  var argv = args.all()

  if @len(argv) == 0
    argv = ["list"]
  end

  if argv[0] == "--help" && @len(argv) == 1
    help()
    return
  end

  if argv[0] == "list"
    if @len(argv) > 2 ||(@len(argv) == 2 && argv[1] != "--all")
      usage()
      return
    end

    const tasks = load_tasks("tasks.txt")
    show_tasks(tasks, @len(argv) == 2)
    return
  end

  if argv[0] == "add" && @len(argv) == 2
    const tasks = load_tasks("tasks.txt")
    add_task(tasks, argv[1])
    save_tasks("tasks.txt", tasks)
    @println(`Added task ${@len(tasks)}.`)
    return
  end

  usage()
end

try run() catch err
  case is fs.FsError.OperationFailed
    @eprintln(`tasks: cannot ${err.operation} '${err.path}': ${err.message}`)
    @exit(1)
  case is TaskError.InvalidData
    @eprintln(`tasks: invalid data on line ${err.line}; expected [ ] title or [x] title`)
    @exit(1)
  case is TaskError.InvalidTitle
    @eprintln("tasks: title must be nonempty and on one line")
    @exit(2)
  case _
    @eprintln("tasks: unexpected error")
    @exit(1)
end
```

</details>

## Try it yourself

Try adding an empty title with `tea tasks.tea add ""`, then list all tasks. Did the rejected operation change the file?

<details>
<summary>Expected result</summary>

The command reports that the title must be nonempty and on one line. No task is appended, and the saved file stays unchanged. The next successful addition will still use the next available position.

</details>

You now have persistent state: one process changes data that another process can read. Next, we'll update an existing task.

Read more: [filesystem writes](/reference/fs), [collection semantics](/docs/types).

<TutorialNavigation slug="tutorial-add-tasks" position="bottom" />

</ContentPage>
